From cecc934da485617d85420bad8fdb727f38bf75a3 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sat, 27 Jun 2026 11:56:21 +0900 Subject: [PATCH 01/79] feat(layout): improve alignment and connector routing - Align single leaf icons to the Y-center of sibling groups' leaves (not the group bounding box center), eliminating padding-induced offset - Align corresponding leaves across vertical groups at any depth so that horizontally connected nodes share the same Y coordinate - Reduce SNAP_THRESHOLD to 5px and remove mid-point averaging so connectors always start/end at the icon center - Make fan-out/fan-in port merging opt-in via "fan": "merge" on connections (default: separate ports per connection) --- skill/sdpm/layout/__init__.py | 243 ++++++++++++++++++++++++++++++++-- 1 file changed, 234 insertions(+), 9 deletions(-) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 0f571785..e7ed9549 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -88,6 +88,21 @@ def sv(v): nx = pb[0] + (pb[2] - cb[2]) // 2 _layout_translate(child, nx - cb[0], ny - cb[1]) + # Post-process 1: align corresponding leaves across sibling vertical groups + # so that e.g. Lambda(row1) in group A has the same Y as DynamoDB(row1) in group B. + if direction == "horizontal": + _align_corresponding_leaves_y(ordered) + elif direction == "vertical": + _align_corresponding_leaves_x(ordered) + + # Post-process 2: align leaf nodes to the median leaf center of sibling groups. + # This ensures single icons sit at the visual center of adjacent vertical groups + # rather than at the center of the group's bounding box (which includes padding). + if align == "center" and direction == "horizontal": + _align_leaves_to_sibling_centers(ordered) + elif align == "center" and direction == "vertical": + _align_leaves_to_sibling_centers_h(ordered) + min_x = min(c["_bindings"][0] - c["_margin"]["left"] for c in children) min_y = min(c["_bindings"][1] - c["_margin"]["top"] for c in children) max_x = max(c["_bindings"][0] + c["_bindings"][2] + c["_margin"]["right"] for c in children) @@ -103,6 +118,103 @@ def sv(v): node["_padding"] = padding +def _align_corresponding_leaves_y(ordered): + """Align Y of corresponding leaves across all vertical groups in the subtree. + + Collects all vertical groups (at any depth) with the same leaf count and + aligns their Nth leaves to the same Y center. + """ + vertical_groups = [] + for child in ordered: + _collect_vertical_groups(child, vertical_groups) + + if len(vertical_groups) < 2: + return + + by_count = {} + for group, leaves in vertical_groups: + n = len(leaves) + by_count.setdefault(n, []).append((group, leaves)) + + for groups_with_same_count in by_count.values(): + if len(groups_with_same_count) < 2: + continue + leaf_count = len(groups_with_same_count[0][1]) + for row_idx in range(leaf_count): + row_leaves = [leaves[row_idx] for _, leaves in groups_with_same_count] + centers = [leaf["_bindings"][1] + leaf["_bindings"][3] // 2 for leaf in row_leaves] + target_cy = max(centers) + for leaf in row_leaves: + b = leaf["_bindings"] + current_cy = b[1] + b[3] // 2 + dy = target_cy - current_cy + if dy != 0: + _layout_translate(leaf, 0, dy) + + +def _align_corresponding_leaves_x(ordered): + """Align X of corresponding leaves across all horizontal groups in the subtree.""" + horizontal_groups = [] + for child in ordered: + _collect_horizontal_groups(child, horizontal_groups) + + if len(horizontal_groups) < 2: + return + + by_count = {} + for group, leaves in horizontal_groups: + n = len(leaves) + by_count.setdefault(n, []).append((group, leaves)) + + for groups_with_same_count in by_count.values(): + if len(groups_with_same_count) < 2: + continue + leaf_count = len(groups_with_same_count[0][1]) + for col_idx in range(leaf_count): + col_leaves = [leaves[col_idx] for _, leaves in groups_with_same_count] + centers = [leaf["_bindings"][0] + leaf["_bindings"][2] // 2 for leaf in col_leaves] + target_cx = max(centers) + for leaf in col_leaves: + b = leaf["_bindings"] + current_cx = b[0] + b[2] // 2 + dx = target_cx - current_cx + if dx != 0: + _layout_translate(leaf, dx, 0) + + +def _collect_vertical_groups(node, out): + """Recursively collect vertical groups with their direct leaves.""" + if not node.get("children"): + return + if node.get("direction", "horizontal") == "vertical": + leaves = [c for c in node["children"] if not c.get("children")] + if leaves: + out.append((node, leaves)) + for child in node.get("children", []): + _collect_vertical_groups(child, out) + + +def _collect_horizontal_groups(node, out): + """Recursively collect horizontal groups with their direct leaves.""" + if not node.get("children"): + return + if node.get("direction", "horizontal") == "horizontal": + leaves = [c for c in node["children"] if not c.get("children")] + if leaves: + out.append((node, leaves)) + for child in node.get("children", []): + _collect_horizontal_groups(child, out) + + +def _get_direct_leaves(node): + """Get direct leaf children (non-recursively) of a node.""" + leaves = [] + for child in node.get("children", []): + if not child.get("children"): + leaves.append(child) + return leaves + + def _layout_translate(node, dx, dy): """Translate node and all descendants by (dx, dy).""" b = node["_bindings"] @@ -111,6 +223,113 @@ def _layout_translate(node, dx, dy): _layout_translate(child, dx, dy) +def _find_leaf_centers_y(node): + """Collect Y-centers of all leaf nodes in a subtree.""" + if not node.get("children"): + b = node["_bindings"] + return [b[1] + b[3] // 2] + centers = [] + for child in node["children"]: + centers.extend(_find_leaf_centers_y(child)) + return centers + + +def _find_leaf_centers_x(node): + """Collect X-centers of all leaf nodes in a subtree.""" + if not node.get("children"): + b = node["_bindings"] + return [b[0] + b[2] // 2] + centers = [] + for child in node["children"]: + centers.extend(_find_leaf_centers_x(child)) + return centers + + +def _align_leaves_to_sibling_centers(ordered): + """For horizontal layout: align leaf Y-center to sibling groups' direct-child leaf Y-center. + + Prioritizes leaves from groups with the same direction (horizontal), + since those represent the main flow continuation. + """ + # Collect cy of direct-child leaves from sibling groups with same direction + same_dir_leaf_centers = [] + for child in ordered: + if child.get("children") and child.get("direction", "horizontal") == "horizontal": + for grandchild in child["children"]: + if not grandchild.get("children"): + b = grandchild["_bindings"] + same_dir_leaf_centers.append(b[1] + b[3] // 2) + + # Fallback: direct-child leaves from any group + if not same_dir_leaf_centers: + for child in ordered: + if child.get("children"): + for grandchild in child["children"]: + if not grandchild.get("children"): + b = grandchild["_bindings"] + same_dir_leaf_centers.append(b[1] + b[3] // 2) + + # Final fallback: all leaf centers + if not same_dir_leaf_centers: + for child in ordered: + if child.get("children"): + centers = _find_leaf_centers_y(child) + if centers: + same_dir_leaf_centers.extend(centers) + + if not same_dir_leaf_centers: + return + + target_cy = (min(same_dir_leaf_centers) + max(same_dir_leaf_centers)) // 2 + + for child in ordered: + if not child.get("children"): + b = child["_bindings"] + current_cy = b[1] + b[3] // 2 + dy = target_cy - current_cy + if dy != 0: + _layout_translate(child, 0, dy) + + +def _align_leaves_to_sibling_centers_h(ordered): + """For vertical layout: align leaf X-center to sibling groups' direct-child leaf X-center.""" + same_dir_leaf_centers = [] + for child in ordered: + if child.get("children") and child.get("direction", "horizontal") == "vertical": + for grandchild in child["children"]: + if not grandchild.get("children"): + b = grandchild["_bindings"] + same_dir_leaf_centers.append(b[0] + b[2] // 2) + + if not same_dir_leaf_centers: + for child in ordered: + if child.get("children"): + for grandchild in child["children"]: + if not grandchild.get("children"): + b = grandchild["_bindings"] + same_dir_leaf_centers.append(b[0] + b[2] // 2) + + if not same_dir_leaf_centers: + for child in ordered: + if child.get("children"): + centers = _find_leaf_centers_x(child) + if centers: + same_dir_leaf_centers.extend(centers) + + if not same_dir_leaf_centers: + return + + target_cx = (min(same_dir_leaf_centers) + max(same_dir_leaf_centers)) // 2 + + for child in ordered: + if not child.get("children"): + b = child["_bindings"] + current_cx = b[0] + b[2] // 2 + dx = target_cx - current_cx + if dx != 0: + _layout_translate(child, dx, 0) + + def _layout_collect(node, nodes_out, groups_out, prefix=""): """Collect flat node/group dicts from tree.""" nid = prefix + node["id"] if prefix else node["id"] @@ -193,7 +412,7 @@ def _layout_route_connections(connections, nodes, groups=None): points = _elbow_path(sp, tp, src_side, dst_side, obstacles) edges.append({"from": conn["from"], "to": conn["to"], "label": conn.get("label", ""), "points": points}) - # T8: Align bend positions for fan-out (same src+side) and fan-in (same dst+side) + # T8: Align bend positions for fan-out/fan-in only when "fan": "merge" is set _align_fan_bends(edges, conn_sides, connections) return edges @@ -204,12 +423,18 @@ def _layout_route_connections(connections, nodes, groups=None): def _align_fan_bends(edges, conn_sides, connections): - """Align bend positions and merge ports for fan-out and fan-in groups.""" - # Fan-out: same src + same src_side + """Align bend positions and merge ports for fan-out and fan-in groups. + + Only activates when connections have "fan": "merge" set. + Default behavior keeps ports separate (split). + """ + # Fan-out: same src + same src_side, only if all connections in the group have fan=merge src_groups = {} for i, (src, dst, src_side, dst_side) in enumerate(conn_sides): if src is None or len(edges[i]["points"]) <= 2: continue + if connections[i].get("fan") != "merge": + continue k = (connections[i]["from"], src_side) src_groups.setdefault(k, []).append(i) @@ -218,11 +443,13 @@ def _align_fan_bends(edges, conn_sides, connections): continue _rewrite_fan(edges, conn_sides, indices, mode="fan_out") - # Fan-in: same dst + same dst_side + # Fan-in: same dst + same dst_side, only if all connections in the group have fan=merge dst_groups = {} for i, (src, dst, src_side, dst_side) in enumerate(conn_sides): if src is None or len(edges[i]["points"]) <= 2: continue + if connections[i].get("fan") != "merge": + continue k = (connections[i]["to"], dst_side) dst_groups.setdefault(k, []).append(i) @@ -350,7 +577,7 @@ def _port_point(node, side, index, count, label_h): return [round(x + w * t), y] -SNAP_THRESHOLD = 15 +SNAP_THRESHOLD = 5 MIN_BEND_MARGIN = 20 OBSTACLE_MARGIN = 10 @@ -377,14 +604,12 @@ def _elbow_path(sp, tp, src_side, dst_side, obstacles=None): tx, ty = tp if src_side in ("left", "right") and dst_side in ("left", "right"): if abs(sy - ty) <= SNAP_THRESHOLD: - mid_y = (sy + ty) // 2 - return [[sx, mid_y], [tx, mid_y]] + return [[sx, sy], [tx, sy]] mx = _calc_bend((sx + tx) // 2, min(sx, tx), max(sx, tx), obstacles, "x") return [[sx, sy], [mx, sy], [mx, ty], [tx, ty]] if src_side in ("top", "bottom") and dst_side in ("top", "bottom"): if abs(sx - tx) <= SNAP_THRESHOLD: - mid_x = (sx + tx) // 2 - return [[mid_x, sy], [mid_x, ty]] + return [[sx, sy], [sx, ty]] my = _calc_bend((sy + ty) // 2, min(sy, ty), max(sy, ty), obstacles, "y") return [[sx, sy], [sx, my], [tx, my], [tx, ty]] if src_side in ("left", "right"): From 7a0d09fd2e733191fe31448722467a2bcf5bb5fe Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sat, 27 Jun 2026 12:09:23 +0900 Subject: [PATCH 02/79] feat(layout): auto-optimize node order and spread overlapping bends - Add optimize_order() pre-processing that sorts leaf nodes within vertical/horizontal groups by their connection source position, eliminating edge crossings automatically - Add _spread_overlapping_bends() post-processing that detects elbow connectors with bend points at nearly the same X coordinate and spaces them apart (20px step) to prevent visual overlap --- skill/scripts/pptx_builder.py | 4 + skill/sdpm/layout/__init__.py | 170 ++++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+) diff --git a/skill/scripts/pptx_builder.py b/skill/scripts/pptx_builder.py index cff11695..36c18392 100644 --- a/skill/scripts/pptx_builder.py +++ b/skill/scripts/pptx_builder.py @@ -40,6 +40,7 @@ _layout_scale, _layout_translate, box_to_elements, + optimize_order, ) from sdpm.preview.backend import _is_wsl from sdpm.utils.effects import apply_effects # noqa: F401 @@ -400,6 +401,9 @@ def cmd_layout(args): align = tree.get("align", "center") reverse = tree.get("reverse", False) + # Pre-process: optimize node order within groups to minimize edge crossings + optimize_order(tree) + def build_root(): import copy root = {"id": "_root", "children": copy.deepcopy(tree.get("children", tree.get("nodes", []))), diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index e7ed9549..08be6258 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -3,6 +3,79 @@ """Layout engine: compute coordinates from logical structure JSON.""" +def optimize_order(tree): + """Pre-process: reorder children in vertical/horizontal groups to minimize + edge crossings based on connection source/target positions. + + Mutates tree in-place. Call before _layout_scale. + """ + connections = tree.get("connections", []) + if not connections: + return + + # Build index: node_id -> position in source list (approximation of Y/X order) + # We use the order of appearance in the flattened tree as positional proxy. + flat_order = [] + _flatten_ids(tree, flat_order) + id_position = {nid: i for i, nid in enumerate(flat_order)} + + # For each group, sort leaf children by the average position of their connected peers + _optimize_group_order(tree, connections, id_position) + + +def _flatten_ids(node, out): + """Collect all node ids in DFS order.""" + if "id" in node: + out.append(node["id"]) + for child in node.get("children", []): + _flatten_ids(child, out) + + +def _optimize_group_order(node, connections, id_position): + """Recursively optimize child order within groups to reduce crossings.""" + children = node.get("children", []) + if not children: + return + + # Recurse first (bottom-up) + for child in children: + _optimize_group_order(child, connections, id_position) + + # Only reorder if all children are leaves (no nested groups) + # to avoid breaking intentional group structure + leaf_children = [c for c in children if not c.get("children")] + if len(leaf_children) < 2 or len(leaf_children) != len(children): + return + + # For each leaf child, compute a "connection weight" based on + # the positions of nodes it connects to/from + def connection_weight(child_id): + weights = [] + for conn in connections: + if conn["from"] == child_id and conn["to"] in id_position: + weights.append(id_position[conn["to"]]) + if conn["to"] == child_id and conn["from"] in id_position: + weights.append(id_position[conn["from"]]) + if not weights: + return id_position.get(child_id, 0) + return sum(weights) / len(weights) + + # Sort by the average position of connected source nodes + # This ensures nodes connected to earlier sources appear first + def sort_key(child): + cid = child.get("id", "") + src_positions = [] + for conn in connections: + if conn["to"] == cid and conn["from"] in id_position: + src_positions.append(id_position[conn["from"]]) + if src_positions: + return sum(src_positions) / len(src_positions) + # Fallback: position of targets + return connection_weight(cid) + + node["children"] = sorted(children, key=sort_key) + + def _layout_scale(node, parent_dir="horizontal", parent_align="center", spacing_scale_h=1.0, spacing_scale_v=1.0): """Recursive layout engine. Calculates bindings (x, y, width, height) for each node bottom-up.""" children = node.get("children", []) @@ -415,6 +488,9 @@ def _layout_route_connections(connections, nodes, groups=None): # T8: Align bend positions for fan-out/fan-in only when "fan": "merge" is set _align_fan_bends(edges, conn_sides, connections) + # T9: Spread overlapping elbow bends from the same source + _spread_overlapping_bends(edges, conn_sides, connections) + return edges @@ -459,6 +535,100 @@ def _align_fan_bends(edges, conn_sides, connections): _rewrite_fan(edges, conn_sides, indices, mode="fan_in") +_BEND_OVERLAP_THRESHOLD = 15 +_BEND_SPREAD_STEP = 20 + + +def _spread_overlapping_bends(edges, conn_sides, connections): + """Detect and spread elbow bends that overlap from the same source node. + + When multiple elbows from the same source have bend segments at nearly + the same X (or Y), space them apart to avoid visual overlap. + """ + # Group edges by source node + src_edge_groups = {} + for i, (src, dst, src_side, dst_side) in enumerate(conn_sides): + if src is None: + continue + pts = edges[i]["points"] + if len(pts) < 4: + continue + k = connections[i]["from"] + src_edge_groups.setdefault(k, []).append(i) + + for indices in src_edge_groups.values(): + if len(indices) < 2: + continue + # Check if bends are on vertical segments (H-V-H pattern) + # For H-V-H: bend is at pts[1][0] (= pts[2][0]) + bend_xs = [] + for idx in indices: + pts = edges[idx]["points"] + if len(pts) >= 4: + # H-V-H: first segment is horizontal, bend is vertical + if abs(pts[0][1] - pts[1][1]) < 5: + bend_xs.append((idx, pts[1][0])) + if len(bend_xs) < 2: + continue + + # Sort by bend X position + bend_xs.sort(key=lambda t: t[1]) + + # Check for overlaps and spread + for j in range(1, len(bend_xs)): + prev_idx, prev_x = bend_xs[j - 1] + curr_idx, curr_x = bend_xs[j] + if abs(curr_x - prev_x) < _BEND_OVERLAP_THRESHOLD: + # Spread apart + new_prev_x = prev_x - _BEND_SPREAD_STEP // 2 + new_curr_x = curr_x + _BEND_SPREAD_STEP // 2 + _update_bend_x(edges[prev_idx]["points"], prev_x, new_prev_x) + _update_bend_x(edges[curr_idx]["points"], curr_x, new_curr_x) + bend_xs[j - 1] = (prev_idx, new_prev_x) + bend_xs[j] = (curr_idx, new_curr_x) + + # Same for destination node (fan-in) + dst_edge_groups = {} + for i, (src, dst, src_side, dst_side) in enumerate(conn_sides): + if src is None: + continue + pts = edges[i]["points"] + if len(pts) < 4: + continue + k = connections[i]["to"] + dst_edge_groups.setdefault(k, []).append(i) + + for indices in dst_edge_groups.values(): + if len(indices) < 2: + continue + bend_xs = [] + for idx in indices: + pts = edges[idx]["points"] + if len(pts) >= 4: + if abs(pts[-1][1] - pts[-2][1]) < 5: + bend_xs.append((idx, pts[-2][0])) + if len(bend_xs) < 2: + continue + bend_xs.sort(key=lambda t: t[1]) + for j in range(1, len(bend_xs)): + prev_idx, prev_x = bend_xs[j - 1] + curr_idx, curr_x = bend_xs[j] + if abs(curr_x - prev_x) < _BEND_OVERLAP_THRESHOLD: + new_prev_x = prev_x - _BEND_SPREAD_STEP // 2 + new_curr_x = curr_x + _BEND_SPREAD_STEP // 2 + _update_bend_x(edges[prev_idx]["points"], prev_x, new_prev_x) + _update_bend_x(edges[curr_idx]["points"], curr_x, new_curr_x) + bend_xs[j - 1] = (prev_idx, new_prev_x) + bend_xs[j] = (curr_idx, new_curr_x) + + +def _update_bend_x(points, old_x, new_x): + """Update bend X coordinate in a 4-point elbow path.""" + for pt in points: + if abs(pt[0] - old_x) < 3: + pt[0] = new_x + + _FAN_BEND_MARGIN = 30 From e8e3ca018a8ee4e39ee22033d53b1de2e415dbf2 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sat, 27 Jun 2026 12:22:55 +0900 Subject: [PATCH 03/79] feat(layout): detect edge-edge crossings and collinear overlap - Add _segments_cross() that detects perpendicular crossings AND collinear overlap (parallel segments on the same axis with >5px shared range) - Add edge-edge crossing warning in cmd_layout output without skipping shared-node edges (shared endpoints don't prevent mid-segment crossings) - Fix KeyError when group has no label in size warnings --- skill/scripts/pptx_builder.py | 78 ++++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 2 deletions(-) diff --git a/skill/scripts/pptx_builder.py b/skill/scripts/pptx_builder.py index 36c18392..9d49ed3f 100644 --- a/skill/scripts/pptx_builder.py +++ b/skill/scripts/pptx_builder.py @@ -388,6 +388,54 @@ def cmd_code_block(args): print(out_str) +def _segments_cross(a1, a2, b1, b2): + """Test if two axis-aligned line segments (a1-a2) and (b1-b2) cross or overlap. + + Detects: + 1. Perpendicular crossings (one horizontal, one vertical) + 2. Collinear overlap (parallel segments sharing the same axis with overlapping range) + """ + ax1, ay1 = a1 + ax2, ay2 = a2 + bx1, by1 = b1 + bx2, by2 = b2 + + a_horiz = ay1 == ay2 + a_vert = ax1 == ax2 + b_horiz = by1 == by2 + b_vert = bx1 == bx2 + + # Perpendicular crossings + if a_horiz and b_vert: + h_y = ay1 + h_x_min, h_x_max = min(ax1, ax2), max(ax1, ax2) + v_x = bx1 + v_y_min, v_y_max = min(by1, by2), max(by1, by2) + return h_x_min < v_x < h_x_max and v_y_min < h_y < v_y_max + if a_vert and b_horiz: + v_x = ax1 + v_y_min, v_y_max = min(ay1, ay2), max(ay1, ay2) + h_y = by1 + h_x_min, h_x_max = min(bx1, bx2), max(bx1, bx2) + return h_x_min < v_x < h_x_max and v_y_min < h_y < v_y_max + + # Collinear overlap: both horizontal on same Y + if a_horiz and b_horiz and ay1 == by1: + a_min, a_max = min(ax1, ax2), max(ax1, ax2) + b_min, b_max = min(bx1, bx2), max(bx1, bx2) + overlap = min(a_max, b_max) - max(a_min, b_min) + return overlap > 5 + + # Collinear overlap: both vertical on same X + if a_vert and b_vert and ax1 == bx1: + a_min, a_max = min(ay1, ay2), max(ay1, ay2) + b_min, b_max = min(by1, by2), max(by1, by2) + overlap = min(a_max, b_max) - max(a_min, b_min) + return overlap > 5 + + return False + + def cmd_layout(args): """Layout engine: compute coordinates from logical structure JSON.""" if args.input == "-": @@ -598,10 +646,11 @@ def build_root(): for gid, g in groups_out.items(): children = g.get("children", []) if len(children) >= 3: + glabel = g.get("label", gid.rsplit(".", 1)[-1]) if target_h and g["height"] > (target_h * 0.6): - warnings.append(f"Group \"{g['label']}\" is tall ({g['height']}px). Consider direction: horizontal for its children.") + warnings.append(f"Group \"{glabel}\" is tall ({g['height']}px). Consider direction: horizontal for its children.") if target_w and g["width"] > (target_w * 0.8): - warnings.append(f"Group \"{g['label']}\" is wide ({g['width']}px). Consider direction: vertical for its children.") + warnings.append(f"Group \"{glabel}\" is wide ({g['width']}px). Consider direction: vertical for its children.") # Check label overlaps label_rects = [] @@ -655,6 +704,31 @@ def build_root(): warnings.append(f'Edge {edge_key} crosses node "{n.get("label", nid)}". Reorder nodes so connected elements are adjacent, or group branch targets in the perpendicular direction. Also consider reverse: true on the target group if connections flow opposite to layout direction.{suggest}') crossing_reported.add(report_key) + # Check edge-edge crossings (segment intersection) + edge_crossing_reported = set() + for i in range(len(edges_out)): + pts_i = edges_out[i]["points"] + if len(pts_i) < 2: + continue + for j in range(i + 1, len(edges_out)): + pts_j = edges_out[j]["points"] + if len(pts_j) < 2: + continue + crossed = False + for si in range(len(pts_i) - 1): + if crossed: + break + for sj in range(len(pts_j) - 1): + if _segments_cross(pts_i[si], pts_i[si + 1], pts_j[sj], pts_j[sj + 1]): + key_ij = (min(i, j), max(i, j)) + if key_ij not in edge_crossing_reported: + e_i = f"{edges_out[i]['from']}→{edges_out[i]['to']}" + e_j = f"{edges_out[j]['from']}→{edges_out[j]['to']}" + warnings.append(f"Edges {e_i} and {e_j} cross each other. Consider reordering nodes or restructuring groups to eliminate the crossing.") + edge_crossing_reported.add(key_ij) + crossed = True + break + # Structure suggestions: sibling size imbalance all_items = {} all_items.update(groups_out) From e9dfb96332b5278629cfcb8c35906f64758ec533 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sat, 27 Jun 2026 12:32:47 +0900 Subject: [PATCH 04/79] feat(layout): replace heuristic sort with brute-force min-crossing search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - optimize_order now uses brute-force permutation search (≤7 leaves) with actual crossing count, falling back to heuristic for larger groups - Fix circular reference in peer position estimation: external peers now get fixed positions based on connection order, independent of the permutation being tested - Correctly minimizes edge crossings in groups like [Aurora, SQS, DynamoDB, ElastiCache] where simple position-average sorting fails --- skill/sdpm/layout/__init__.py | 196 +++++++++++++++++++++++++--------- 1 file changed, 145 insertions(+), 51 deletions(-) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 08be6258..9dbd88b6 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -4,76 +4,170 @@ def optimize_order(tree): - """Pre-process: reorder children in vertical/horizontal groups to minimize - edge crossings based on connection source/target positions. + """Pre-process: reorder children in leaf-only groups to minimize edge crossings. + + Uses brute-force permutation search for small groups (≤7 leaves) and + heuristic sorting for larger ones. Counts actual crossing pairs to find + the optimal order. Mutates tree in-place. Call before _layout_scale. """ connections = tree.get("connections", []) if not connections: return - - # Build index: node_id -> position in source list (approximation of Y/X order) - # We use the order of appearance in the flattened tree as positional proxy. - flat_order = [] - _flatten_ids(tree, flat_order) - id_position = {nid: i for i, nid in enumerate(flat_order)} - - # For each group, sort leaf children by the average position of their connected peers - _optimize_group_order(tree, connections, id_position) - - -def _flatten_ids(node, out): - """Collect all node ids in DFS order.""" - if "id" in node: - out.append(node["id"]) - for child in node.get("children", []): - _flatten_ids(child, out) + _optimize_group_order(tree, connections) -def _optimize_group_order(node, connections, id_position): - """Recursively optimize child order within groups to reduce crossings.""" +def _optimize_group_order(node, connections): + """Recursively optimize child order within leaf-only groups.""" children = node.get("children", []) if not children: return - # Recurse first (bottom-up) for child in children: - _optimize_group_order(child, connections, id_position) + _optimize_group_order(child, connections) - # Only reorder if all children are leaves (no nested groups) - # to avoid breaking intentional group structure leaf_children = [c for c in children if not c.get("children")] if len(leaf_children) < 2 or len(leaf_children) != len(children): return - # For each leaf child, compute a "connection weight" based on - # the positions of nodes it connects to/from - def connection_weight(child_id): - weights = [] - for conn in connections: - if conn["from"] == child_id and conn["to"] in id_position: - weights.append(id_position[conn["to"]]) - if conn["to"] == child_id and conn["from"] in id_position: - weights.append(id_position[conn["from"]]) - if not weights: - return id_position.get(child_id, 0) - return sum(weights) / len(weights) + leaf_ids = [c["id"] for c in children] + + # Collect connections relevant to this group's leaves + relevant = [] + for conn in connections: + src, dst = conn["from"], conn["to"] + src_in = src in leaf_ids + dst_in = dst in leaf_ids + if src_in or dst_in: + relevant.append(conn) + + if not relevant: + return + + # For brute-force: try all permutations if ≤7 leaves + if len(children) <= 7: + best_order = _find_min_crossing_order(children, relevant, connections) + if best_order is not None: + node["children"] = best_order + return + + # Fallback heuristic for larger groups: sort by connected peer position + flat_order = [] + _flatten_ids_from_root(node, flat_order) + id_position = {nid: i for i, nid in enumerate(flat_order)} + node["children"] = sorted(children, key=lambda c: _heuristic_sort_key(c["id"], connections, id_position)) - # Sort by the average position of connected source nodes - # This ensures nodes connected to earlier sources appear first - def sort_key(child): - cid = child.get("id", "") - src_positions = [] - for conn in connections: - if conn["to"] == cid and conn["from"] in id_position: - src_positions.append(id_position[conn["from"]]) - if src_positions: - return sum(src_positions) / len(src_positions) - # Fallback: position of targets - return connection_weight(cid) - - node["children"] = sorted(children, key=sort_key) + +def _find_min_crossing_order(children, relevant, all_connections): + """Try all permutations and return the one with fewest crossings.""" + from itertools import permutations + + best_crossings = None + best_perm = None + + # Determine fixed external peer positions from the tree structure + peer_positions = _compute_peer_positions(children, all_connections) + + for perm in permutations(children): + perm_ids = [c["id"] for c in perm] + crossings = _count_crossings_for_order(perm_ids, relevant, peer_positions) + if best_crossings is None or crossings < best_crossings: + best_crossings = crossings + best_perm = list(perm) + if crossings == 0: + break + + return best_perm + + +def _compute_peer_positions(children, all_connections): + """Compute fixed positions for external peers (nodes outside this group). + + External peers are assigned positions based on their relative order among + each other — determined by their index in the sibling group they belong to. + This position is independent of the permutation being tested. + """ + leaf_ids = set(c["id"] for c in children) + # Collect all external peers connected to this group + peers = set() + for conn in all_connections: + if conn["from"] in leaf_ids and conn["to"] not in leaf_ids: + peers.add(conn["to"]) + if conn["to"] in leaf_ids and conn["from"] not in leaf_ids: + peers.add(conn["from"]) + + # Group external peers by which group-internal nodes they connect to. + # Peers connecting to the same set of internal nodes should have the same position. + # Peers are ordered by their first appearance in connections list. + peer_order = [] + seen = set() + for conn in all_connections: + for p in [conn["from"], conn["to"]]: + if p in peers and p not in seen: + peer_order.append(p) + seen.add(p) + + # Assign a simple sequential position based on order of appearance + return {p: i for i, p in enumerate(peer_order)} + + +def _count_crossings_for_order(ordered_ids, relevant, peer_positions): + """Count edge crossings given a specific ordering of nodes in a group. + + Uses fixed peer_positions for external nodes and the permutation positions + for internal nodes. Two edges cross if their endpoint orders are inverted. + """ + pos = {nid: i for i, nid in enumerate(ordered_ids)} + id_set = set(ordered_ids) + + edges = [] + for conn in relevant: + src, dst = conn["from"], conn["to"] + if src in id_set and dst in id_set: + edges.append((pos[src], pos[dst])) + elif src in id_set: + ext_pos = peer_positions.get(dst, len(ordered_ids) / 2) + edges.append((pos[src], ext_pos)) + elif dst in id_set: + ext_pos = peer_positions.get(src, len(ordered_ids) / 2) + edges.append((ext_pos, pos[dst])) + + crossings = 0 + for i in range(len(edges)): + for j in range(i + 1, len(edges)): + a1, b1 = edges[i] + a2, b2 = edges[j] + if (a1 - a2) * (b1 - b2) < 0: + crossings += 1 + return crossings + + +def _flatten_ids_from_root(node, out): + """Collect all node ids in DFS order from a subtree root.""" + if "id" in node: + out.append(node["id"]) + for child in node.get("children", []): + _flatten_ids_from_root(child, out) + + +def _heuristic_sort_key(child_id, connections, id_position): + """Fallback heuristic: sort by average position of connected source nodes.""" + src_positions = [] + for conn in connections: + if conn["to"] == child_id and conn["from"] in id_position: + src_positions.append(id_position[conn["from"]]) + if src_positions: + return sum(src_positions) / len(src_positions) + weights = [] + for conn in connections: + if conn["from"] == child_id and conn["to"] in id_position: + weights.append(id_position[conn["to"]]) + if conn["to"] == child_id and conn["from"] in id_position: + weights.append(id_position[conn["from"]]) + if weights: + return sum(weights) / len(weights) + return id_position.get(child_id, 0) def _layout_scale(node, parent_dir="horizontal", parent_align="center", spacing_scale_h=1.0, spacing_scale_v=1.0): From d5dc8dbff38bfba2f133dc5f97ea9c3b5cdf2867 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sat, 27 Jun 2026 12:45:08 +0900 Subject: [PATCH 05/79] feat(layout): optimize port assignment order to minimize crossings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add _optimize_port_order() that tries all permutations of port indices (≤6 ports per side) and picks the assignment with fewest segment crossings among edges sharing that port group - Reduces edge-edge crossings from 3 to 2 in the complex feedback loop test case (remaining crossings require path detour routing) - Includes heuristic fallback for nodes with >6 ports on one side --- skill/sdpm/layout/__init__.py | 165 ++++++++++++++++++++++++++++++++-- 1 file changed, 159 insertions(+), 6 deletions(-) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 9dbd88b6..2de4111e 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -557,15 +557,18 @@ def _layout_route_connections(connections, nodes, groups=None): port_counts[sk] = port_counts.get(sk, 0) + 1 port_counts[dk] = port_counts.get(dk, 0) + 1 - port_cursors = {} + # Optimize port assignment order to minimize crossings. + # Group connections by (node, side), then try permutations of port order. + port_groups = {} for i, (src, dst, src_side, dst_side) in enumerate(conn_sides): if src is None: continue - for nid, side in [(connections[i]["from"], src_side), (connections[i]["to"], dst_side)]: - k = (nid, side) - port_cursors[k] = port_cursors.get(k, 0) - port_indices[(i, nid)] = port_cursors[k] - port_cursors[k] += 1 + sk = (connections[i]["from"], src_side) + dk = (connections[i]["to"], dst_side) + port_groups.setdefault(sk, []).append(i) + port_groups.setdefault(dk, []).append(i) + + port_indices = _optimize_port_order(port_groups, conn_sides, connections, nodes, port_counts, obstacles) edges = [] for i, conn in enumerate(connections): @@ -588,6 +591,156 @@ def _layout_route_connections(connections, nodes, groups=None): return edges +def _optimize_port_order(port_groups, conn_sides, connections, nodes, port_counts, obstacles): + """Find the port index assignment that minimizes edge crossings. + + For each (node, side) with multiple ports, try all permutations (≤6) + and pick the one with fewest crossings. For larger groups, use a + heuristic: sort ports by the Y (or X) coordinate of the peer endpoint. + """ + from itertools import permutations + + # Start with sequential assignment + port_indices = {} + port_cursors = {} + for i, (src, dst, src_side, dst_side) in enumerate(conn_sides): + if src is None: + continue + for nid, side in [(connections[i]["from"], src_side), (connections[i]["to"], dst_side)]: + k = (nid, side) + port_cursors[k] = port_cursors.get(k, 0) + port_indices[(i, nid)] = port_cursors[k] + port_cursors[k] += 1 + + # For each port group with ≥2 connections, optimize the order + for (nid, side), conn_indices in port_groups.items(): + if len(conn_indices) < 2: + continue + + count = port_counts[(nid, side)] + node = _find_node(nodes, nid) + if not node: + continue + + if len(conn_indices) <= 6: + # Brute force: try all permutations + best_crossings = None + best_assignment = None + + for perm in permutations(range(count)): + # Assign port indices according to this permutation + test_indices = dict(port_indices) + for slot, ci in enumerate(conn_indices): + test_indices[(ci, nid)] = perm[slot] + + crossings = _count_port_crossings(conn_indices, test_indices, conn_sides, connections, nodes, port_counts, obstacles) + if best_crossings is None or crossings < best_crossings: + best_crossings = crossings + best_assignment = perm + if crossings == 0: + break + + if best_assignment is not None: + for slot, ci in enumerate(conn_indices): + port_indices[(ci, nid)] = best_assignment[slot] + else: + # Heuristic: sort by peer endpoint coordinate + _heuristic_port_sort(conn_indices, nid, side, port_indices, conn_sides, connections, nodes) + + return port_indices + + +def _count_port_crossings(conn_indices, port_indices, conn_sides, connections, nodes, port_counts, obstacles): + """Count crossings among a set of edges sharing a port group.""" + # Generate paths for the relevant connections + paths = [] + for ci in conn_indices: + src, dst, src_side, dst_side = conn_sides[ci] + if src is None: + continue + label_h = 30 if src.get("label") else 0 + sp = _port_point(src, src_side, port_indices[(ci, connections[ci]["from"])], port_counts[(connections[ci]["from"], src_side)], label_h) + tp = _port_point(dst, dst_side, port_indices[(ci, connections[ci]["to"])], port_counts[(connections[ci]["to"], dst_side)], label_h) + path = _elbow_path(sp, tp, src_side, dst_side, obstacles) + paths.append(path) + + # Count pairwise segment crossings + crossings = 0 + for i in range(len(paths)): + for j in range(i + 1, len(paths)): + for si in range(len(paths[i]) - 1): + for sj in range(len(paths[j]) - 1): + if _segments_intersect(paths[i][si], paths[i][si + 1], paths[j][sj], paths[j][sj + 1]): + crossings += 1 + return crossings + + +def _segments_intersect(a1, a2, b1, b2): + """Test if two axis-aligned segments cross or overlap (for port optimization).""" + ax1, ay1 = a1 + ax2, ay2 = a2 + bx1, by1 = b1 + bx2, by2 = b2 + + a_horiz = ay1 == ay2 + a_vert = ax1 == ax2 + b_horiz = by1 == by2 + b_vert = bx1 == bx2 + + if a_horiz and b_vert: + h_y = ay1 + h_x_min, h_x_max = min(ax1, ax2), max(ax1, ax2) + v_x = bx1 + v_y_min, v_y_max = min(by1, by2), max(by1, by2) + return h_x_min < v_x < h_x_max and v_y_min < h_y < v_y_max + if a_vert and b_horiz: + v_x = ax1 + v_y_min, v_y_max = min(ay1, ay2), max(ay1, ay2) + h_y = by1 + h_x_min, h_x_max = min(bx1, bx2), max(bx1, bx2) + return h_x_min < v_x < h_x_max and v_y_min < h_y < v_y_max + if a_horiz and b_horiz and ay1 == by1: + a_min, a_max = min(ax1, ax2), max(ax1, ax2) + b_min, b_max = min(bx1, bx2), max(bx1, bx2) + return min(a_max, b_max) - max(a_min, b_min) > 5 + if a_vert and b_vert and ax1 == bx1: + a_min, a_max = min(ay1, ay2), max(ay1, ay2) + b_min, b_max = min(by1, by2), max(by1, by2) + return min(a_max, b_max) - max(a_min, b_min) > 5 + return False + + +def _heuristic_port_sort(conn_indices, nid, side, port_indices, conn_sides, connections, nodes): + """Sort ports by peer endpoint coordinate when brute force is too expensive.""" + peer_coords = [] + for ci in conn_indices: + src, dst, src_side, dst_side = conn_sides[ci] + if connections[ci]["from"] == nid: + peer = _find_node(nodes, connections[ci]["to"]) + coord = (peer["y"] + peer["height"] // 2) if peer else 0 + else: + peer = _find_node(nodes, connections[ci]["from"]) + coord = (peer["y"] + peer["height"] // 2) if peer else 0 + peer_coords.append((coord, ci)) + + # Sort by peer Y coordinate (or X for vertical sides) + if side in ("top", "bottom"): + for ci in conn_indices: + src, dst, src_side, dst_side = conn_sides[ci] + if connections[ci]["from"] == nid: + peer = _find_node(nodes, connections[ci]["to"]) + coord = (peer["x"] + peer["width"] // 2) if peer else 0 + else: + peer = _find_node(nodes, connections[ci]["from"]) + coord = (peer["x"] + peer["width"] // 2) if peer else 0 + peer_coords.append((coord, ci)) + peer_coords = peer_coords[len(conn_indices):] + + peer_coords.sort(key=lambda t: t[0]) + for slot, (_, ci) in enumerate(peer_coords): + port_indices[(ci, nid)] = slot + + # Max spread between dst (or src) centers to allow grouping _FAN_SPREAD_LIMIT = 600 From bd88363cbb3fa504fd6d509d8789d7ad61e33745 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sat, 27 Jun 2026 13:19:41 +0900 Subject: [PATCH 06/79] feat(layout): search-based bend optimization to resolve all crossings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace fixed-direction shift with exhaustive candidate search: for each crossing, try both edges × ±10/15/20/30/40/50px × X/Y axis - Score candidates by (total_crossing_count, displacement) to pick the shift that minimizes crossings with least movement - Iterate up to 30 times until zero crossings remain - Resolves all edge-edge crossings in the GenAI chat app architecture (previously 3 unresolvable crossings) --- skill/sdpm/layout/__init__.py | 209 ++++++++++++++++++++++------------ 1 file changed, 134 insertions(+), 75 deletions(-) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 2de4111e..fb233041 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -782,91 +782,150 @@ def _align_fan_bends(edges, conn_sides, connections): _rewrite_fan(edges, conn_sides, indices, mode="fan_in") -_BEND_OVERLAP_THRESHOLD = 15 -_BEND_SPREAD_STEP = 20 +_MAX_RESOLVE_ITERATIONS = 30 +_BEND_CANDIDATES = [-50, -40, -30, -20, -15, -10, 10, 15, 20, 30, 40, 50] def _spread_overlapping_bends(edges, conn_sides, connections): - """Detect and spread elbow bends that overlap from the same source node. + """Iteratively resolve edge crossings by searching for optimal bend shifts. - When multiple elbows from the same source have bend segments at nearly - the same X (or Y), space them apart to avoid visual overlap. + For each crossing found, tries all candidate shifts on both involved edges, + evaluates by (crossing_count, displacement), and applies the best fix. + Repeats until zero crossings or max iterations. """ - # Group edges by source node - src_edge_groups = {} - for i, (src, dst, src_side, dst_side) in enumerate(conn_sides): - if src is None: - continue - pts = edges[i]["points"] - if len(pts) < 4: - continue - k = connections[i]["from"] - src_edge_groups.setdefault(k, []).append(i) + import copy - for indices in src_edge_groups.values(): - if len(indices) < 2: - continue - # Check if bends are on vertical segments (H-V-H pattern) - # For H-V-H: bend is at pts[1][0] (= pts[2][0]) - bend_xs = [] - for idx in indices: - pts = edges[idx]["points"] - if len(pts) >= 4: - # H-V-H: first segment is horizontal, bend is vertical - if abs(pts[0][1] - pts[1][1]) < 5: - bend_xs.append((idx, pts[1][0])) - if len(bend_xs) < 2: - continue + for _iteration in range(_MAX_RESOLVE_ITERATIONS): + crossing = _find_first_crossing(edges) + if crossing is None: + break + i, si, j, sj = crossing + _resolve_crossing_search(edges, i, si, j, sj) - # Sort by bend X position - bend_xs.sort(key=lambda t: t[1]) - - # Check for overlaps and spread - for j in range(1, len(bend_xs)): - prev_idx, prev_x = bend_xs[j - 1] - curr_idx, curr_x = bend_xs[j] - if abs(curr_x - prev_x) < _BEND_OVERLAP_THRESHOLD: - # Spread apart - new_prev_x = prev_x - _BEND_SPREAD_STEP // 2 - new_curr_x = curr_x + _BEND_SPREAD_STEP // 2 - _update_bend_x(edges[prev_idx]["points"], prev_x, new_prev_x) - _update_bend_x(edges[curr_idx]["points"], curr_x, new_curr_x) - bend_xs[j - 1] = (prev_idx, new_prev_x) - bend_xs[j] = (curr_idx, new_curr_x) - - # Same for destination node (fan-in) - dst_edge_groups = {} - for i, (src, dst, src_side, dst_side) in enumerate(conn_sides): - if src is None: - continue - pts = edges[i]["points"] - if len(pts) < 4: - continue - k = connections[i]["to"] - dst_edge_groups.setdefault(k, []).append(i) - for indices in dst_edge_groups.values(): - if len(indices) < 2: +def _find_first_crossing(edges): + """Find the first pair of crossing segments across all edges.""" + for i in range(len(edges)): + pts_i = edges[i]["points"] + if len(pts_i) < 2: continue - bend_xs = [] - for idx in indices: - pts = edges[idx]["points"] - if len(pts) >= 4: - if abs(pts[-1][1] - pts[-2][1]) < 5: - bend_xs.append((idx, pts[-2][0])) - if len(bend_xs) < 2: + for j in range(i + 1, len(edges)): + pts_j = edges[j]["points"] + if len(pts_j) < 2: + continue + for si in range(len(pts_i) - 1): + for sj in range(len(pts_j) - 1): + if _segments_intersect(pts_i[si], pts_i[si + 1], pts_j[sj], pts_j[sj + 1]): + return (i, si, j, sj) + return None + + +def _count_all_crossings(edges): + """Count total crossing pairs across all edges.""" + count = 0 + for i in range(len(edges)): + pts_i = edges[i]["points"] + if len(pts_i) < 2: continue - bend_xs.sort(key=lambda t: t[1]) - for j in range(1, len(bend_xs)): - prev_idx, prev_x = bend_xs[j - 1] - curr_idx, curr_x = bend_xs[j] - if abs(curr_x - prev_x) < _BEND_OVERLAP_THRESHOLD: - new_prev_x = prev_x - _BEND_SPREAD_STEP // 2 - new_curr_x = curr_x + _BEND_SPREAD_STEP // 2 - _update_bend_x(edges[prev_idx]["points"], prev_x, new_prev_x) - _update_bend_x(edges[curr_idx]["points"], curr_x, new_curr_x) - bend_xs[j - 1] = (prev_idx, new_prev_x) - bend_xs[j] = (curr_idx, new_curr_x) + for j in range(i + 1, len(edges)): + pts_j = edges[j]["points"] + if len(pts_j) < 2: + continue + for si in range(len(pts_i) - 1): + for sj in range(len(pts_j) - 1): + if _segments_intersect(pts_i[si], pts_i[si + 1], pts_j[sj], pts_j[sj + 1]): + count += 1 + return count + + +def _resolve_crossing_search(edges, i, si, j, sj): + """Try all candidate shifts for both crossing edges and pick the best. + + For each candidate: apply shift, count total crossings, score by + (crossings, displacement). Pick minimum. + """ + import copy + + pts_i = edges[i]["points"] + pts_j = edges[j]["points"] + a1, a2 = pts_i[si], pts_i[si + 1] + b1, b2 = pts_j[sj], pts_j[sj + 1] + + current_crossings = _count_all_crossings(edges) + best_score = (current_crossings, 0) + best_patch = None + + # Generate candidates based on segment orientation + candidates = [] + for edge_idx, seg_idx, pt1, pt2 in [(i, si, a1, a2), (j, sj, b1, b2)]: + is_vert = pt1[0] == pt2[0] + is_horiz = pt1[1] == pt2[1] + if is_vert: + for delta in _BEND_CANDIDATES: + candidates.append((edge_idx, "x", seg_idx, delta)) + if is_horiz: + for delta in _BEND_CANDIDATES: + candidates.append((edge_idx, "y", seg_idx, delta)) + + for edge_idx, axis, seg, delta in candidates: + test_edges = copy.deepcopy(edges) + pts = test_edges[edge_idx]["points"] + + if axis == "x": + _apply_bend_shift_x(pts, seg, delta) + else: + _apply_bend_shift_y(pts, seg, delta) + + crossings = _count_all_crossings(test_edges) + score = (crossings, abs(delta)) + + if score < best_score: + best_score = score + best_patch = (edge_idx, copy.deepcopy(test_edges[edge_idx]["points"])) + + if best_patch is not None: + edge_idx, new_pts = best_patch + edges[edge_idx]["points"] = new_pts + + +def _apply_bend_shift_x(points, seg_idx, delta): + """Shift the vertical bend at seg_idx by delta on the X axis. + + Identifies the shared X value of the vertical segment and shifts all + points on that bend column. + """ + if len(points) < 3: + return + p1 = points[seg_idx] + p2 = points[min(seg_idx + 1, len(points) - 1)] + if p1[0] == p2[0]: + target_x = p1[0] + elif seg_idx > 0 and points[seg_idx - 1][0] == p1[0]: + target_x = p1[0] + else: + target_x = p1[0] + + for pt in points: + if pt[0] == target_x: + pt[0] += delta + + +def _apply_bend_shift_y(points, seg_idx, delta): + """Shift the horizontal bend at seg_idx by delta on the Y axis.""" + if len(points) < 3: + return + p1 = points[seg_idx] + p2 = points[min(seg_idx + 1, len(points) - 1)] + if p1[1] == p2[1]: + target_y = p1[1] + elif seg_idx > 0 and points[seg_idx - 1][1] == p1[1]: + target_y = p1[1] + else: + target_y = p1[1] + + for pt in points: + if pt[1] == target_y: + pt[1] += delta def _update_bend_x(points, old_x, new_x): From eb71c25486aae196ee3dd442e3edbffe39790186 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sat, 27 Jun 2026 16:32:06 +0900 Subject: [PATCH 07/79] feat(layout): detour routing for reverse-flow + bend separation improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reverse-flow connections (right-to-left) now route as U-shaped polyline detours below all nodes instead of crossing through them - Reverse connections use dedicated bottom ports, excluded from normal port_counts to avoid disturbing forward-flow port positions - Polyline output for detour paths (uses existing freeform builder) - Separate close horizontal segments by adjusting bend X positions - Align same-source bends to a single X for clean tree-branch appearance - Expand bend candidate range to ±120px for larger diagrams - Add _MIN_BEND_SEPARATION constant (40px) for visual clarity --- skill/scripts/pptx_builder.py | 66 ++++-- skill/sdpm/layout/__init__.py | 391 ++++++++++++++++++++++++++++++++-- 2 files changed, 417 insertions(+), 40 deletions(-) diff --git a/skill/scripts/pptx_builder.py b/skill/scripts/pptx_builder.py index 9d49ed3f..6c12580d 100644 --- a/skill/scripts/pptx_builder.py +++ b/skill/scripts/pptx_builder.py @@ -539,29 +539,49 @@ def build_root(): continue sx, sy = pts[0] ex, ey = pts[-1] - el = {"type": "line", "x1": sx, "y1": sy, "x2": ex, "y2": ey, "arrowEnd": "arrow"} - if len(pts) > 2: - el["connectorType"] = "elbow" - dx = ex - sx - dy = ey - sy - if len(pts) >= 4 and abs(dx) > 0 and abs(dy) > 0: - # 4 points: [start, bend1, bend2, end] - seg1_vertical = abs(pts[0][0] - pts[1][0]) <= abs(pts[0][1] - pts[1][1]) - if seg1_vertical: - # V-H-V → elbowStart vertical - adj = (pts[1][1] - sy) / dy if dy != 0 else 0.5 - el["preset"] = "bentConnector3" - el["elbowStart"] = "vertical" - el["adjustments"] = [max(0.0, min(1.0, adj))] - else: - # H-V-H → bentConnector4 (no flip) - adj1 = (pts[1][0] - sx) / dx if dx != 0 else 0.5 - adj2 = (pts[2][1] - sy) / dy if dy != 0 else 0.5 - el["preset"] = "bentConnector4" - el["adjustments"] = [max(-1.0, min(2.0, adj1)), max(-1.0, min(2.0, adj2))] - elif dy != 0 or dx != 0: - el["adjustments"] = [0.5] - elements.append(el) + + # Detour paths (4+ points, U-shape) or complex routes: emit as polyline + is_detour = len(pts) >= 4 and pts[0][1] == pts[-1][1] and any(p[1] != pts[0][1] for p in pts[1:-1]) + if is_detour or len(pts) >= 6: + el = {"type": "line", "arrowEnd": "arrow", + "points": [[p[0], p[1]] for p in pts]} + elements.append(el) + else: + el = {"type": "line", "x1": sx, "y1": sy, "x2": ex, "y2": ey, "arrowEnd": "arrow"} + if len(pts) > 2: + el["connectorType"] = "elbow" + dx = ex - sx + dy = ey - sy + if len(pts) >= 4 and (abs(dx) > 0 or abs(dy) > 0): + # 4 points: [start, bend1, bend2, end] + seg1_vertical = abs(pts[0][0] - pts[1][0]) <= abs(pts[0][1] - pts[1][1]) + if seg1_vertical: + # V-H-V → elbowStart vertical + # For U-shaped detour (sy==ty), use the bend Y relative to path height + if dy != 0: + adj = (pts[1][1] - sy) / dy + else: + # sy == ty: use max extent as reference + path_max_y = max(p[1] for p in pts) + path_min_y = min(p[1] for p in pts) + path_dy = path_max_y - sy if path_max_y > sy else path_min_y - sy + adj = (pts[1][1] - sy) / path_dy if path_dy != 0 else 0.5 + # Override: place end at same Y as start by using full extent + dy = path_dy + ey = sy + dy + el["y2"] = ey + el["preset"] = "bentConnector3" + el["elbowStart"] = "vertical" + el["adjustments"] = [max(-2.0, min(3.0, adj))] + else: + # H-V-H → bentConnector4 + adj1 = (pts[1][0] - sx) / dx if dx != 0 else 0.5 + adj2 = (pts[2][1] - sy) / dy if dy != 0 else 0.5 + el["preset"] = "bentConnector4" + el["adjustments"] = [max(-1.0, min(2.0, adj1)), max(-1.0, min(2.0, adj2))] + elif dy != 0 or dx != 0: + el["adjustments"] = [0.5] + elements.append(el) label = e.get("label", "") if not label: diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index fb233041..91ec3cd7 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -537,14 +537,25 @@ def _layout_route_connections(connections, nodes, groups=None): port_counts = {} port_indices = {} + # First pass: identify reverse-flow connections (they use bottom ports, not side ports) + reverse_set = set() + for i, conn in enumerate(connections): + src = _find_node(nodes, conn["from"]) + dst = _find_node(nodes, conn["to"]) + if src and dst and dst["x"] + dst["width"] < src["x"]: + reverse_set.add(i) + conn_sides = [] - for conn in connections: + for i, conn in enumerate(connections): src = _find_node(nodes, conn["from"]) dst = _find_node(nodes, conn["to"]) if not src or not dst: conn_sides.append((None, None, None, None)) continue - # Determine group direction if both nodes share a parent group + if i in reverse_set: + # Reverse connections use bottom ports — don't affect side port counts + conn_sides.append((src, dst, "bottom", "bottom")) + continue group_dir = None src_gid = _find_group_for(conn["from"], node_group) dst_gid = _find_group_for(conn["to"], node_group) @@ -559,9 +570,10 @@ def _layout_route_connections(connections, nodes, groups=None): # Optimize port assignment order to minimize crossings. # Group connections by (node, side), then try permutations of port order. + # Exclude reverse connections (they use dedicated bottom ports). port_groups = {} for i, (src, dst, src_side, dst_side) in enumerate(conn_sides): - if src is None: + if src is None or i in reverse_set: continue sk = (connections[i]["from"], src_side) dk = (connections[i]["to"], dst_side) @@ -570,16 +582,33 @@ def _layout_route_connections(connections, nodes, groups=None): port_indices = _optimize_port_order(port_groups, conn_sides, connections, nodes, port_counts, obstacles) + # Compute global bounding box for detour routing + all_y = [] + for n in nodes.values(): + all_y.append(n["y"]) + all_y.append(n["y"] + n["height"]) + global_bottom = max(all_y) + 60 if all_y else 500 + edges = [] for i, conn in enumerate(connections): src, dst, src_side, dst_side = conn_sides[i] if src is None: edges.append({"from": conn["from"], "to": conn["to"], "label": conn.get("label", ""), "points": []}) continue - label_h = 30 if src.get("label") else 0 - sp = _port_point(src, src_side, port_indices[(i, conn["from"])], port_counts[(conn["from"], src_side)], label_h) - tp = _port_point(dst, dst_side, port_indices[(i, conn["to"])], port_counts[(conn["to"], dst_side)], label_h) - points = _elbow_path(sp, tp, src_side, dst_side, obstacles) + + if i in reverse_set: + src_node = _find_node(nodes, conn["from"]) + dst_node = _find_node(nodes, conn["to"]) + label_h_src = 30 if src_node.get("label") else 0 + label_h_dst = 30 if dst_node.get("label") else 0 + sp = _port_point(src_node, "bottom", 0, 1, label_h_src) + tp = _port_point(dst_node, "bottom", 0, 1, label_h_dst) + points = _detour_path(sp, tp, "bottom", "bottom", global_bottom) + else: + label_h = 30 if src.get("label") else 0 + sp = _port_point(src, src_side, port_indices[(i, conn["from"])], port_counts[(conn["from"], src_side)], label_h) + tp = _port_point(dst, dst_side, port_indices[(i, conn["to"])], port_counts[(conn["to"], dst_side)], label_h) + points = _elbow_path(sp, tp, src_side, dst_side, obstacles) edges.append({"from": conn["from"], "to": conn["to"], "label": conn.get("label", ""), "points": points}) # T8: Align bend positions for fan-out/fan-in only when "fan": "merge" is set @@ -783,18 +812,18 @@ def _align_fan_bends(edges, conn_sides, connections): _MAX_RESOLVE_ITERATIONS = 30 -_BEND_CANDIDATES = [-50, -40, -30, -20, -15, -10, 10, 15, 20, 30, 40, 50] +_BEND_CANDIDATES = [-120, -100, -80, -60, -50, -40, -30, -20, -10, 10, 20, 30, 40, 50, 60, 80, 100, 120] def _spread_overlapping_bends(edges, conn_sides, connections): - """Iteratively resolve edge crossings by searching for optimal bend shifts. + """Iteratively resolve edge crossings and separate close bends. - For each crossing found, tries all candidate shifts on both involved edges, - evaluates by (crossing_count, displacement), and applies the best fix. - Repeats until zero crossings or max iterations. + Phase 1: Resolve all crossings by searching for optimal bend shifts. + Phase 2: Separate bends that are too close (even if not crossing). """ import copy + # Phase 1: resolve crossings for _iteration in range(_MAX_RESOLVE_ITERATIONS): crossing = _find_first_crossing(edges) if crossing is None: @@ -802,6 +831,9 @@ def _spread_overlapping_bends(edges, conn_sides, connections): i, si, j, sj = crossing _resolve_crossing_search(edges, i, si, j, sj) + # Phase 2: separate close parallel bends + _separate_close_bends(edges) + def _find_first_crossing(edges): """Find the first pair of crossing segments across all edges.""" @@ -838,11 +870,16 @@ def _count_all_crossings(edges): return count +_MIN_BEND_SEPARATION = 40 + + def _resolve_crossing_search(edges, i, si, j, sj): """Try all candidate shifts for both crossing edges and pick the best. - For each candidate: apply shift, count total crossings, score by - (crossings, displacement). Pick minimum. + Scoring: (crossings, -min_bend_separation, displacement) + 1. Minimize total crossings (most important) + 2. Maximize minimum distance between parallel bends (visual clarity) + 3. Minimize displacement from original position (stability) """ import copy @@ -852,10 +889,10 @@ def _resolve_crossing_search(edges, i, si, j, sj): b1, b2 = pts_j[sj], pts_j[sj + 1] current_crossings = _count_all_crossings(edges) - best_score = (current_crossings, 0) + current_separation = _min_bend_separation(edges) + best_score = (current_crossings, -current_separation, 0) best_patch = None - # Generate candidates based on segment orientation candidates = [] for edge_idx, seg_idx, pt1, pt2 in [(i, si, a1, a2), (j, sj, b1, b2)]: is_vert = pt1[0] == pt2[0] @@ -877,7 +914,8 @@ def _resolve_crossing_search(edges, i, si, j, sj): _apply_bend_shift_y(pts, seg, delta) crossings = _count_all_crossings(test_edges) - score = (crossings, abs(delta)) + separation = _min_bend_separation(test_edges) + score = (crossings, -separation, abs(delta)) if score < best_score: best_score = score @@ -888,6 +926,307 @@ def _resolve_crossing_search(edges, i, si, j, sj): edges[edge_idx]["points"] = new_pts +def _min_bend_separation(edges): + """Calculate the minimum distance between parallel bend segments. + + Checks all pairs of vertical bends (same-ish Y range) for X separation, + and all pairs of horizontal bends (same-ish X range) for Y separation. + Returns the minimum separation found (larger = better visual clarity). + """ + vertical_bends = [] + horizontal_bends = [] + + for e in edges: + pts = e["points"] + if len(pts) < 4: + continue + for k in range(len(pts) - 1): + p1, p2 = pts[k], pts[k + 1] + if p1[0] == p2[0] and abs(p1[1] - p2[1]) > 10: + y_min, y_max = min(p1[1], p2[1]), max(p1[1], p2[1]) + vertical_bends.append((p1[0], y_min, y_max)) + elif p1[1] == p2[1] and abs(p1[0] - p2[0]) > 10: + x_min, x_max = min(p1[0], p2[0]), max(p1[0], p2[0]) + horizontal_bends.append((p1[1], x_min, x_max)) + + min_sep = 9999 + + for a in range(len(vertical_bends)): + for b in range(a + 1, len(vertical_bends)): + ax, ay_min, ay_max = vertical_bends[a] + bx, by_min, by_max = vertical_bends[b] + overlap_y = min(ay_max, by_max) - max(ay_min, by_min) + if overlap_y > 10: + sep = abs(ax - bx) + if sep < min_sep: + min_sep = sep + + for a in range(len(horizontal_bends)): + for b in range(a + 1, len(horizontal_bends)): + ay, ax_min, ax_max = horizontal_bends[a] + by, bx_min, bx_max = horizontal_bends[b] + overlap_x = min(ax_max, bx_max) - max(ax_min, bx_min) + if overlap_x > 10: + sep = abs(ay - by) + if sep < min_sep: + min_sep = sep + + return min_sep + + +def _separate_close_bends(edges): + """Spread parallel bends that are too close, distributing them evenly. + + Groups vertical bends that share a similar X position (within _MIN_BEND_SEPARATION) + AND whose Y ranges are adjacent or overlapping. Spreads their X positions evenly + with _MIN_BEND_SEPARATION between each. + Does not introduce new crossings. + """ + import copy + + # Collect all vertical bend segments: (edge_idx, seg_idx, x, y_min, y_max) + v_bends = [] + for ei, e in enumerate(edges): + pts = e["points"] + for k in range(len(pts) - 1): + if pts[k][0] == pts[k + 1][0] and abs(pts[k][1] - pts[k + 1][1]) > 10: + y_min = min(pts[k][1], pts[k + 1][1]) + y_max = max(pts[k][1], pts[k + 1][1]) + v_bends.append((ei, k, pts[k][0], y_min, y_max)) + + # Group bends that are close in X AND adjacent/overlapping in Y + # BUT: only group bends from DIFFERENT source nodes. + # Bends from the same source should be aligned (not separated). + used = set() + groups = [] + for a in range(len(v_bends)): + if a in used: + continue + group = [a] + group_y_min = v_bends[a][3] + group_y_max = v_bends[a][4] + src_a = edges[v_bends[a][0]]["from"] + for b in range(a + 1, len(v_bends)): + if b in used: + continue + ei_b = v_bends[b][0] + src_b = edges[ei_b]["from"] + # Skip if same source — those should stay aligned + if src_b == src_a: + continue + _, _, bx, by_min, by_max = v_bends[b] + group_x_avg = sum(v_bends[idx][2] for idx in group) // len(group) + if abs(bx - group_x_avg) >= _MIN_BEND_SEPARATION: + continue + gap = max(by_min - group_y_max, group_y_min - by_max) + if gap < 50: + group.append(b) + group_y_min = min(group_y_min, by_min) + group_y_max = max(group_y_max, by_max) + if len(group) < 2: + continue + used.update(group) + groups.append(group) + + # Spread each group evenly + for group in groups: + group_bends = [(v_bends[idx], idx) for idx in group] + group_bends.sort(key=lambda t: (t[0][3] + t[0][4]) / 2) + center_x = sum(v[0][2] for v in group_bends) // len(group_bends) + spread_total = _MIN_BEND_SEPARATION * (len(group_bends) - 1) + start_x = center_x - spread_total // 2 + + current_crossings = _count_all_crossings(edges) + for slot, (bend_info, _) in enumerate(group_bends): + ei, seg_k, old_x, _, _ = bend_info + new_x = start_x + slot * _MIN_BEND_SEPARATION + if new_x == old_x: + continue + test_edges = copy.deepcopy(edges) + delta = new_x - old_x + _apply_bend_shift_x(test_edges[ei]["points"], seg_k, delta) + if _count_all_crossings(test_edges) <= current_crossings: + _apply_bend_shift_x(edges[ei]["points"], seg_k, delta) + + # Align bends from the same source to a single X position + _align_same_source_bends(edges) + + # Also spread close horizontal segments + _separate_close_horizontal_segments(edges) + + +def _align_same_source_bends(edges): + """Align bends from the same source node to a single X (or Y) position. + + When multiple edges fan out from the same node, their vertical bends + should share the same X so they look like a clean tree branch. + Only aligns if it doesn't introduce new crossings. + """ + import copy + + # Group edges by source + src_groups = {} + for ei, e in enumerate(edges): + pts = e["points"] + if len(pts) < 4: + continue + src_groups.setdefault(e["from"], []).append(ei) + + current_crossings = _count_all_crossings(edges) + + for src, edge_indices in src_groups.items(): + if len(edge_indices) < 2: + continue + + # Collect vertical bend X positions for these edges + bend_xs = [] + for ei in edge_indices: + pts = edges[ei]["points"] + for k in range(len(pts) - 1): + if pts[k][0] == pts[k + 1][0] and abs(pts[k][1] - pts[k + 1][1]) > 5: + bend_xs.append((ei, k, pts[k][0])) + break + + if len(bend_xs) < 2: + continue + + # All already aligned? + xs = [x for _, _, x in bend_xs] + if max(xs) - min(xs) <= 5: + continue + + # Try aligning to the median X + target_x = sorted(xs)[len(xs) // 2] + + # Test: align all to target_x + test_edges = copy.deepcopy(edges) + for ei, k, old_x in bend_xs: + if old_x != target_x: + delta = target_x - old_x + _apply_bend_shift_x(test_edges[ei]["points"], k, delta) + + if _count_all_crossings(test_edges) <= current_crossings: + for ei, k, old_x in bend_xs: + if old_x != target_x: + delta = target_x - old_x + _apply_bend_shift_x(edges[ei]["points"], k, delta) + + # Same for destination (fan-in): align bends going to the same target + dst_groups = {} + for ei, e in enumerate(edges): + pts = e["points"] + if len(pts) < 4: + continue + dst_groups.setdefault(e["to"], []).append(ei) + + current_crossings = _count_all_crossings(edges) + + for dst, edge_indices in dst_groups.items(): + if len(edge_indices) < 2: + continue + + bend_xs = [] + for ei in edge_indices: + pts = edges[ei]["points"] + for k in range(len(pts) - 1): + if pts[k][0] == pts[k + 1][0] and abs(pts[k][1] - pts[k + 1][1]) > 5: + bend_xs.append((ei, k, pts[k][0])) + break + + if len(bend_xs) < 2: + continue + + xs = [x for _, _, x in bend_xs] + if max(xs) - min(xs) <= 5: + continue + + target_x = sorted(xs)[len(xs) // 2] + + test_edges = copy.deepcopy(edges) + for ei, k, old_x in bend_xs: + if old_x != target_x: + delta = target_x - old_x + _apply_bend_shift_x(test_edges[ei]["points"], k, delta) + + if _count_all_crossings(test_edges) <= current_crossings: + for ei, k, old_x in bend_xs: + if old_x != target_x: + delta = target_x - old_x + _apply_bend_shift_x(edges[ei]["points"], k, delta) + + +def _separate_close_horizontal_segments(edges): + """Detect horizontal segments at nearly the same Y with overlapping X range. + + When two horizontal segments from different edges are within + _MIN_BEND_SEPARATION/2 in Y and overlap in X, shift one edge's bend + to create visual separation. + """ + import copy + + # Collect all horizontal segments: (edge_idx, seg_idx, y, x_min, x_max) + h_segs = [] + for ei, e in enumerate(edges): + pts = e["points"] + for k in range(len(pts) - 1): + if pts[k][1] == pts[k + 1][1] and abs(pts[k][0] - pts[k + 1][0]) > 20: + x_min = min(pts[k][0], pts[k + 1][0]) + x_max = max(pts[k][0], pts[k + 1][0]) + h_segs.append((ei, k, pts[k][1], x_min, x_max)) + + current_crossings = _count_all_crossings(edges) + adjusted = set() + for a in range(len(h_segs)): + for b in range(a + 1, len(h_segs)): + ei_a, k_a, y_a, xmin_a, xmax_a = h_segs[a] + ei_b, k_b, y_b, xmin_b, xmax_b = h_segs[b] + if ei_a == ei_b: + continue + y_diff = abs(y_a - y_b) + if y_diff >= _MIN_BEND_SEPARATION // 2: + continue + overlap = min(xmax_a, xmax_b) - max(xmin_a, xmin_b) + if overlap <= 20: + continue + + # Try shifting either edge's vertical bend X to shorten/lengthen + # the horizontal segment so they no longer overlap in X. + resolved = False + for ei, k in [(ei_a, k_a), (ei_b, k_b)]: + if ei in adjusted or resolved: + continue + pts = edges[ei]["points"] + # Find the vertical bend in this edge + for vk in range(len(pts) - 1): + if pts[vk][0] == pts[vk + 1][0] and abs(pts[vk][1] - pts[vk + 1][1]) > 5: + # Try shifting this bend X to reduce horizontal overlap + other_xmin = xmin_b if ei == ei_a else xmin_a + other_xmax = xmax_b if ei == ei_a else xmax_a + # Shift bend to just before or after the other segment + for delta in [-60, -40, 60, 40, -80, 80, -100, 100]: + test_edges = copy.deepcopy(edges) + _apply_bend_shift_x(test_edges[ei]["points"], vk, delta) + # Check: overlap reduced AND no new crossings + new_crossings = _count_all_crossings(test_edges) + # Recalculate overlap + new_pts = test_edges[ei]["points"] + new_h_y = None + for nk in range(len(new_pts) - 1): + if new_pts[nk][1] == new_pts[nk + 1][1] and abs(new_pts[nk][0] - new_pts[nk + 1][0]) > 20: + new_xmin = min(new_pts[nk][0], new_pts[nk + 1][0]) + new_xmax = max(new_pts[nk][0], new_pts[nk + 1][0]) + if abs(new_pts[nk][1] - (y_b if ei == ei_a else y_a)) < _MIN_BEND_SEPARATION // 2: + new_overlap = min(new_xmax, other_xmax) - max(new_xmin, other_xmin) + if new_overlap <= 20 and new_crossings <= current_crossings: + _apply_bend_shift_x(edges[ei]["points"], vk, delta) + adjusted.add(ei) + resolved = True + break + if resolved: + break + break + + def _apply_bend_shift_x(points, seg_idx, delta): """Shift the vertical bend at seg_idx by delta on the X axis. @@ -1074,6 +1413,24 @@ def _calc_bend(val, lo, hi, obstacles, axis): return val +_DETOUR_MARGIN = 40 + + +def _detour_path(sp, tp, src_side, dst_side, global_bottom): + """Generate a U-shaped detour path for reverse-flow connections. + + Routes below all nodes: src → down → across → up → dst + Always produces a 4-point path (コの字): + [src] → [src_x, bottom] → [dst_x, bottom] → [dst] + """ + sx, sy = sp + tx, ty = tp + bottom_y = global_bottom + _DETOUR_MARGIN + + # Always route: straight down from src, horizontal across bottom, straight up to dst + return [[sx, sy], [sx, bottom_y], [tx, bottom_y], [tx, ty]] + + def _elbow_path(sp, tp, src_side, dst_side, obstacles=None): obstacles = obstacles or [] sx, sy = sp From 42ee28385d2adaea71d4dfe5a619f961863c66b3 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sat, 27 Jun 2026 17:02:52 +0900 Subject: [PATCH 08/79] feat(layout): consistent fan-out port sides and dst_side correction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Force all forward connections from the same source to exit from the same side (decided by the first connection's _auto_sides result) - Correct dst_side when src_side is overridden (e.g. right→top becomes right→left) to ensure arrows enter target icons from the correct side - Skip _align_same_source_bends for fan-out patterns (where start Y positions differ) to prevent horizontal segment overlap --- skill/sdpm/layout/__init__.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 91ec3cd7..8ddeb9dc 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -545,6 +545,9 @@ def _layout_route_connections(connections, nodes, groups=None): if src and dst and dst["x"] + dst["width"] < src["x"]: reverse_set.add(i) + # Track decided sides per source node to ensure consistency for fan-out + decided_src_side = {} + conn_sides = [] for i, conn in enumerate(connections): src = _find_node(nodes, conn["from"]) @@ -553,7 +556,6 @@ def _layout_route_connections(connections, nodes, groups=None): conn_sides.append((None, None, None, None)) continue if i in reverse_set: - # Reverse connections use bottom ports — don't affect side port counts conn_sides.append((src, dst, "bottom", "bottom")) continue group_dir = None @@ -562,6 +564,24 @@ def _layout_route_connections(connections, nodes, groups=None): if src_gid and src_gid == dst_gid: group_dir = groups[src_gid].get("direction", "horizontal") src_side, dst_side = _auto_sides(src, dst, group_dir) + + # Consistency: if this source already has a decided side for forward connections, + # reuse it to prevent some arrows exiting from a different side (e.g. bottom) + src_id = conn["from"] + if src_id in decided_src_side: + src_side = decided_src_side[src_id] + # Also fix dst_side: if src is "right", dst should be "left" (not "top") + if src_side == "right" and dst_side == "top": + dst_side = "left" + elif src_side == "left" and dst_side == "bottom": + dst_side = "right" + elif src_side == "bottom" and dst_side == "right": + dst_side = "top" + elif src_side == "top" and dst_side == "left": + dst_side = "bottom" + else: + decided_src_side[src_id] = src_side + conn_sides.append((src, dst, src_side, dst_side)) sk = (conn["from"], src_side) dk = (conn["to"], dst_side) @@ -1078,6 +1098,11 @@ def _align_same_source_bends(edges): if len(edge_indices) < 2: continue + # Skip alignment if start Y positions differ (fan-out with distributed ports) + start_ys = [edges[ei]["points"][0][1] for ei in edge_indices] + if max(start_ys) - min(start_ys) > 10: + continue + # Collect vertical bend X positions for these edges bend_xs = [] for ei in edge_indices: From 53e688bb52576f6477c56fe099c48e3b2c2c80c0 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sat, 27 Jun 2026 17:04:34 +0900 Subject: [PATCH 09/79] fix(layout): center leaf nodes at nearest sibling group median Y MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix _align_leaves_to_sibling_centers fallback: use the nearest sibling group's leaf center median for perfect alignment - Browser now sits exactly at WAF group's vertical midpoint (0px diff) - user→waf2 becomes a straight line (no L-shape from rounding error) --- skill/sdpm/layout/__init__.py | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 8ddeb9dc..c04b70db 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -427,22 +427,17 @@ def _align_leaves_to_sibling_centers(ordered): b = grandchild["_bindings"] same_dir_leaf_centers.append(b[1] + b[3] // 2) - # Fallback: direct-child leaves from any group + # Fallback: for each leaf, find the adjacent group and use its leaf median if not same_dir_leaf_centers: - for child in ordered: - if child.get("children"): - for grandchild in child["children"]: - if not grandchild.get("children"): - b = grandchild["_bindings"] - same_dir_leaf_centers.append(b[1] + b[3] // 2) - - # Final fallback: all leaf centers - if not same_dir_leaf_centers: - for child in ordered: - if child.get("children"): - centers = _find_leaf_centers_y(child) - if centers: - same_dir_leaf_centers.extend(centers) + leaf_indices = [i for i, c in enumerate(ordered) if not c.get("children")] + group_indices = [i for i, c in enumerate(ordered) if c.get("children")] + if leaf_indices and group_indices: + # Use the group nearest to the first leaf + first_leaf_idx = leaf_indices[0] + nearest_group_idx = min(group_indices, key=lambda g: abs(g - first_leaf_idx)) + centers = _find_leaf_centers_y(ordered[nearest_group_idx]) + if centers: + same_dir_leaf_centers.append((min(centers) + max(centers)) // 2) if not same_dir_leaf_centers: return From 593586af5bd77bd6064b14469bfd59fd280c41af Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sat, 27 Jun 2026 17:13:47 +0900 Subject: [PATCH 10/79] fix(layout): never move port-anchored endpoints during bend adjustment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _apply_bend_shift_x/y now skip points[0] and points[-1] (start/end) which are anchored to icon port positions - _apply_bend_shift_y additionally skips if target_y matches start/end Y to prevent creating diagonal segments from port-adjacent points - Fixes Lambda→Transcribe start Y being shifted from icon center and prevents non-axis-aligned (diagonal) segments in elbow paths --- skill/sdpm/layout/__init__.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index c04b70db..7331d070 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -1250,8 +1250,7 @@ def _separate_close_horizontal_segments(edges): def _apply_bend_shift_x(points, seg_idx, delta): """Shift the vertical bend at seg_idx by delta on the X axis. - Identifies the shared X value of the vertical segment and shifts all - points on that bend column. + Never moves the first or last point (port-anchored endpoints). """ if len(points) < 3: return @@ -1264,14 +1263,21 @@ def _apply_bend_shift_x(points, seg_idx, delta): else: target_x = p1[0] - for pt in points: + for i, pt in enumerate(points): + if i == 0 or i == len(points) - 1: + continue if pt[0] == target_x: pt[0] += delta def _apply_bend_shift_y(points, seg_idx, delta): - """Shift the horizontal bend at seg_idx by delta on the Y axis.""" - if len(points) < 3: + """Shift the horizontal bend at seg_idx by delta on the Y axis. + + Never moves the first or last point (port-anchored endpoints). + Only shifts points that are part of an internal horizontal segment + (not adjacent to the start/end points). + """ + if len(points) < 4: return p1 = points[seg_idx] p2 = points[min(seg_idx + 1, len(points) - 1)] @@ -1282,7 +1288,13 @@ def _apply_bend_shift_y(points, seg_idx, delta): else: target_y = p1[1] - for pt in points: + # Don't shift if target_y matches start or end Y (would break port alignment) + if target_y == points[0][1] or target_y == points[-1][1]: + return + + for i, pt in enumerate(points): + if i == 0 or i == len(points) - 1: + continue if pt[1] == target_y: pt[1] += delta From a1aa013f1e8426f8ae3726b22d8eadcfcefb70db Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sat, 27 Jun 2026 22:18:28 +0900 Subject: [PATCH 11/79] feat(layout): add graph layout engine and improve routing for non-tree diagrams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add constraint-based graph layout engine (skill/sdpm/layout/graph.py) - Fix reverse_set: skip when explicit side hints provided, require horizontal displacement > vertical * 0.5 to avoid treating vertical connections as reverse - Fix decided_src_side: skip consistency override when explicit sides provided - Support srcSide/dstSide hints in connection spec for _layout_route_connections - Add internal_order_constraints to optimize_order: preserve order of nodes connected within the same group (prevents SDK→NW becoming NW→SDK) - Fix KeyError when group has no 'label' in imbalance warning --- skill/scripts/pptx_builder.py | 2 +- skill/sdpm/layout/__init__.py | 74 +++++-- skill/sdpm/layout/graph.py | 363 ++++++++++++++++++++++++++++++++++ 3 files changed, 421 insertions(+), 18 deletions(-) create mode 100644 skill/sdpm/layout/graph.py diff --git a/skill/scripts/pptx_builder.py b/skill/scripts/pptx_builder.py index 6c12580d..c7842635 100644 --- a/skill/scripts/pptx_builder.py +++ b/skill/scripts/pptx_builder.py @@ -788,7 +788,7 @@ def build_root(): content_area = max(content_w, 1) * max(content_h, 1) child_area = sum(c["width"] * c["height"] for _, c in child_bboxes) eff = round(child_area / content_area * 100) - warnings.append(f"Group \"{g['label']}\" children {axis} imbalance: \"{max_label}\"={max_s}px vs \"{min_label}\"={min_s}px (ratio {ratio:.1f}:1, packing {eff}%). Consider redistributing children or changing direction. Note: restructuring may affect arrow routing.") + warnings.append(f"Group \"{g.get('label', g.get('id', '?'))}\" children {axis} imbalance: \"{max_label}\"={max_s}px vs \"{min_label}\"={min_s}px (ratio {ratio:.1f}:1, packing {eff}%). Consider redistributing children or changing direction. Note: restructuring may affect arrow routing.") if warnings: output["warnings"] = warnings diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 7331d070..8c13b089 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -45,9 +45,17 @@ def _optimize_group_order(node, connections): if not relevant: return + # Identify internal connections (both src and dst within this group) + # Their src must appear before dst in any valid permutation. + internal_order_constraints = [] + for conn in connections: + src, dst = conn["from"], conn["to"] + if src in leaf_ids and dst in leaf_ids: + internal_order_constraints.append((src, dst)) + # For brute-force: try all permutations if ≤7 leaves if len(children) <= 7: - best_order = _find_min_crossing_order(children, relevant, connections) + best_order = _find_min_crossing_order(children, relevant, connections, internal_order_constraints) if best_order is not None: node["children"] = best_order return @@ -59,7 +67,7 @@ def _optimize_group_order(node, connections): node["children"] = sorted(children, key=lambda c: _heuristic_sort_key(c["id"], connections, id_position)) -def _find_min_crossing_order(children, relevant, all_connections): +def _find_min_crossing_order(children, relevant, all_connections, internal_order_constraints=None): """Try all permutations and return the one with fewest crossings.""" from itertools import permutations @@ -71,6 +79,18 @@ def _find_min_crossing_order(children, relevant, all_connections): for perm in permutations(children): perm_ids = [c["id"] for c in perm] + + # Skip permutations that violate internal order constraints + if internal_order_constraints: + valid = True + for src, dst in internal_order_constraints: + if src in perm_ids and dst in perm_ids: + if perm_ids.index(src) > perm_ids.index(dst): + valid = False + break + if not valid: + continue + crossings = _count_crossings_for_order(perm_ids, relevant, peer_positions) if best_crossings is None or crossings < best_crossings: best_crossings = crossings @@ -533,12 +553,21 @@ def _layout_route_connections(connections, nodes, groups=None): port_indices = {} # First pass: identify reverse-flow connections (they use bottom ports, not side ports) + # Skip if explicit side hints are provided (graph layout mode). + # Only treat as reverse if the horizontal displacement is dominant (not a vertical connection). reverse_set = set() for i, conn in enumerate(connections): + if conn.get("srcSide") or conn.get("dstSide"): + continue src = _find_node(nodes, conn["from"]) dst = _find_node(nodes, conn["to"]) if src and dst and dst["x"] + dst["width"] < src["x"]: - reverse_set.add(i) + src_cy = src["y"] + src.get("height", 60) / 2 + dst_cy = dst["y"] + dst.get("height", 60) / 2 + dx = src["x"] - (dst["x"] + dst["width"]) + dy = abs(dst_cy - src_cy) + if dx > dy * 0.5: + reverse_set.add(i) # Track decided sides per source node to ensure consistency for fan-out decided_src_side = {} @@ -553,6 +582,11 @@ def _layout_route_connections(connections, nodes, groups=None): if i in reverse_set: conn_sides.append((src, dst, "bottom", "bottom")) continue + + # Allow explicit side hints from connection spec + explicit_src = conn.get("srcSide") + explicit_dst = conn.get("dstSide") + group_dir = None src_gid = _find_group_for(conn["from"], node_group) dst_gid = _find_group_for(conn["to"], node_group) @@ -560,22 +594,28 @@ def _layout_route_connections(connections, nodes, groups=None): group_dir = groups[src_gid].get("direction", "horizontal") src_side, dst_side = _auto_sides(src, dst, group_dir) + if explicit_src: + src_side = explicit_src + if explicit_dst: + dst_side = explicit_dst + # Consistency: if this source already has a decided side for forward connections, - # reuse it to prevent some arrows exiting from a different side (e.g. bottom) + # reuse it to prevent some arrows exiting from a different side (e.g. bottom). + # Skip this override when explicit sides are provided. src_id = conn["from"] - if src_id in decided_src_side: - src_side = decided_src_side[src_id] - # Also fix dst_side: if src is "right", dst should be "left" (not "top") - if src_side == "right" and dst_side == "top": - dst_side = "left" - elif src_side == "left" and dst_side == "bottom": - dst_side = "right" - elif src_side == "bottom" and dst_side == "right": - dst_side = "top" - elif src_side == "top" and dst_side == "left": - dst_side = "bottom" - else: - decided_src_side[src_id] = src_side + if not explicit_src and not explicit_dst: + if src_id in decided_src_side: + src_side = decided_src_side[src_id] + if src_side == "right" and dst_side == "top": + dst_side = "left" + elif src_side == "left" and dst_side == "bottom": + dst_side = "right" + elif src_side == "bottom" and dst_side == "right": + dst_side = "top" + elif src_side == "top" and dst_side == "left": + dst_side = "bottom" + else: + decided_src_side[src_id] = src_side conn_sides.append((src, dst, src_side, dst_side)) sk = (conn["from"], src_side) diff --git a/skill/sdpm/layout/graph.py b/skill/sdpm/layout/graph.py new file mode 100644 index 00000000..9d5e32a9 --- /dev/null +++ b/skill/sdpm/layout/graph.py @@ -0,0 +1,363 @@ +"""Graph layout engine: constraint-based placement for non-tree diagrams. + +Input format: +{ + "mode": "graph", + "nodes": { + "monitor": {"label": "監視(DNA)", "icon": "..."}, + ... + }, + "constraints": [ + {"type": "above", "node": "monitor", "target": "clap"}, + {"type": "right_of", "node": "prod_nw", "target": "clap"}, + {"type": "v_list", "nodes": ["orchestrator", "verify_agent", "review_agent", "gen_agent"]}, + {"type": "h_list", "nodes": ["operator", "manual_doc", "related_docs"]}, + {"type": "align_y", "nodes": ["verify_agent", "wf_yaml"]}, + {"type": "align_x", "nodes": ["monitor", "clap"]}, + ], + "edges": [...], + "groups": [...] +} + +Constraint types: + - above: node is above target (same X, node.y < target.y) + - below: node is below target + - left_of: node is to the left of target (same Y) + - right_of: node is to the right of target (same Y) + - v_list: nodes arranged vertically, equally spaced + - h_list: nodes arranged horizontally, equally spaced + - align_y: nodes share the same Y center + - align_x: nodes share the same X center + +Coordinate system: 1920x1080 px (EMU_PER_PX = 6350, 1px = 0.5pt on 960x540 slide). +""" + +from . import _layout_route_connections + +_SLIDE_W = 1920 +_SLIDE_H = 1080 +_MARGIN_TOP = 160 +_MARGIN_BOTTOM = 80 +_MARGIN_LEFT = 100 +_MARGIN_RIGHT = 100 + + +def layout_graph(spec, target_x=None, target_y=None, target_w=None, target_h=None, theme="dark"): + """Compute graph layout from constraint-based spec.""" + target_x = target_x if target_x is not None else _MARGIN_LEFT + target_y = target_y if target_y is not None else _MARGIN_TOP + target_w = target_w if target_w is not None else (_SLIDE_W - _MARGIN_LEFT - _MARGIN_RIGHT) + target_h = target_h if target_h is not None else (_SLIDE_H - _MARGIN_TOP - _MARGIN_BOTTOM) + + node_defs = spec.get("nodes", {}) + constraints = spec.get("constraints", []) + edges = spec.get("edges", []) + group_defs = spec.get("groups", []) + icon_size = spec.get("iconSize", 110) + gap_h = spec.get("gapH", 200) + gap_v = spec.get("gapV", 160) + + # --- Phase 1: Solve constraints to get relative positions --- + positions = _solve_constraints(list(node_defs.keys()), constraints, icon_size, gap_h, gap_v) + + # --- Phase 2: Scale and translate to fit target area --- + if not positions: + return [], {}, {} + + min_x = min(p[0] for p in positions.values()) + min_y = min(p[1] for p in positions.values()) + max_x = max(p[0] for p in positions.values()) + max_y = max(p[1] for p in positions.values()) + + range_x = max_x - min_x if max_x > min_x else 1 + range_y = max_y - min_y if max_y > min_y else 1 + + # Scale to fit, preserving aspect ratio + scale_x = (target_w - icon_size) / range_x if range_x > 0 else 1.0 + scale_y = (target_h - icon_size) / range_y if range_y > 0 else 1.0 + scale = min(scale_x, scale_y, 1.0) + + # Center within target area + scaled_w = range_x * scale + scaled_h = range_y * scale + offset_x = target_x + (target_w - scaled_w - icon_size) / 2 + offset_y = target_y + (target_h - scaled_h - icon_size) / 2 + + nodes_out = {} + for nid, (rx, ry) in positions.items(): + nx = round(offset_x + (rx - min_x) * scale) + ny = round(offset_y + (ry - min_y) * scale) + node_def = node_defs.get(nid, {}) + nodes_out[nid] = { + "x": nx, "y": ny, + "width": icon_size, "height": icon_size, + "icon": node_def.get("icon", ""), + "label": node_def.get("label", nid), + } + + # --- Phase 3: Build groups --- + groups_out = {} + for gdef in group_defs: + gid = gdef["id"] + gnodes = gdef.get("nodes", []) + contained = [nodes_out[nid] for nid in gnodes if nid in nodes_out] + if not contained: + continue + pad_x, pad_top, pad_bot = 35, 55, 35 + gx = min(n["x"] for n in contained) - pad_x + gy = min(n["y"] for n in contained) - pad_top + gx2 = max(n["x"] + n["width"] for n in contained) + pad_x + gy2 = max(n["y"] + n["height"] for n in contained) + pad_bot + groups_out[gid] = { + "x": gx, "y": gy, + "width": gx2 - gx, "height": gy2 - gy, + "label": gdef.get("label", gid), + "groupType": f"generic-{gdef.get('style', 'dashed')}", + "children": gnodes, + } + + # --- Phase 4: Route connections --- + connections = [] + for e in edges: + conn = {"from": e["from"], "to": e["to"]} + src = nodes_out.get(e["from"]) + dst = nodes_out.get(e["to"]) + if src and dst: + sides = _compute_sides(src, dst) + conn["srcSide"] = sides[0] + conn["dstSide"] = sides[1] + connections.append(conn) + edges_out = _layout_route_connections(connections, nodes_out, groups_out) + + edge_map = {(e["from"], e["to"]): e for e in edges} + for eo in edges_out: + edef = edge_map.get((eo["from"], eo["to"]), {}) + if edef.get("label"): + eo["label"] = edef["label"] + if edef.get("lineWidth"): + eo["lineWidth"] = edef["lineWidth"] + + # --- Phase 5: Build elements --- + elements = _build_elements(nodes_out, groups_out, edges_out) + return elements, nodes_out, groups_out + + +def _solve_constraints(node_ids, constraints, icon_size, gap_h, gap_v): + """Solve positional constraints using iterative relaxation. + + Strategy: list constraints establish structure, then relative/align + constraints adjust positions without breaking list structure. + """ + pos = {nid: [0.0, 0.0] for nid in node_ids} + + # Track which axis is locked per node (from list constraints) + x_locked = {} # nid → list_id (nodes in h_list have locked relative X) + y_locked = {} # nid → list_id (nodes in v_list have locked relative Y) + list_groups = [] # (axis, nodes, gap) + + # Phase 1: Apply list constraints — these define relative positions within groups + for ci, c in enumerate(constraints): + ctype = c["type"] + if ctype == "h_list": + nodes = c["nodes"] + g = c.get("gap", gap_h) + base_x = pos[nodes[0]][0] + for i, nid in enumerate(nodes): + pos[nid][0] = base_x + i * (icon_size + g) + x_locked[nid] = ci + list_groups.append(("x", nodes, g)) + elif ctype == "v_list": + nodes = c["nodes"] + g = c.get("gap", gap_v) + base_y = pos[nodes[0]][1] + for i, nid in enumerate(nodes): + pos[nid][1] = base_y + i * (icon_size + g) + y_locked[nid] = ci + list_groups.append(("y", nodes, g)) + + # Phase 2: Iteratively apply relative and alignment constraints + # These move entire groups when a node belongs to a list + for _ in range(20): + for c in constraints: + ctype = c["type"] + if ctype == "above": + node, target = c["node"], c["target"] + needed_y = pos[target][1] - icon_size - gap_v + if pos[node][1] > needed_y: + _shift_node_y(pos, node, needed_y, y_locked, list_groups) + elif ctype == "below": + node, target = c["node"], c["target"] + needed_y = pos[target][1] + icon_size + gap_v + if pos[node][1] < needed_y: + _shift_node_y(pos, node, needed_y, y_locked, list_groups) + elif ctype == "right_of": + node, target = c["node"], c["target"] + needed_x = pos[target][0] + icon_size + gap_h + if pos[node][0] < needed_x: + _shift_node_x(pos, node, needed_x, x_locked, list_groups) + elif ctype == "left_of": + node, target = c["node"], c["target"] + needed_x = pos[target][0] - icon_size - gap_h + if pos[node][0] > needed_x: + _shift_node_x(pos, node, needed_x, x_locked, list_groups) + elif ctype == "align_y": + nodes = c["nodes"] + avg_y = sum(pos[n][1] for n in nodes) / len(nodes) + for n in nodes: + _shift_node_y(pos, n, avg_y, y_locked, list_groups) + elif ctype == "align_x": + nodes = c["nodes"] + avg_x = sum(pos[n][0] for n in nodes) / len(nodes) + for n in nodes: + _shift_node_x(pos, n, avg_x, x_locked, list_groups) + + # Phase 3: Resolve overlaps — push overlapping nodes apart + node_list = list(pos.keys()) + for _ in range(20): + moved = False + for i in range(len(node_list)): + for j in range(i + 1, len(node_list)): + a, b = node_list[i], node_list[j] + dx = abs(pos[a][0] - pos[b][0]) + dy = abs(pos[a][1] - pos[b][1]) + if dx < icon_size * 0.9 and dy < icon_size * 0.9: + # Push apart in the axis with more freedom + if a not in x_locked and b not in x_locked: + pos[b][0] += icon_size + gap_h * 0.5 + moved = True + elif a not in y_locked and b not in y_locked: + pos[b][1] += icon_size + gap_v * 0.5 + moved = True + else: + # Both locked — push the unlocked axis + if a not in x_locked: + pos[a][0] -= icon_size + gap_h * 0.5 + moved = True + elif b not in x_locked: + pos[b][0] += icon_size + gap_h * 0.5 + moved = True + if not moved: + break + + return {nid: (p[0], p[1]) for nid, p in pos.items()} + + +def _shift_node_x(pos, nid, new_x, x_locked, list_groups): + """Move a node's X. If it's in an h_list, shift the whole list.""" + if nid in x_locked: + # Find the list group and shift all members + lid = x_locked[nid] + delta = new_x - pos[nid][0] + for axis, nodes, gap in list_groups: + if axis == "x" and nid in nodes: + for n in nodes: + pos[n][0] += delta + return + pos[nid][0] = new_x + + +def _shift_node_y(pos, nid, new_y, y_locked, list_groups): + """Move a node's Y. If it's in a v_list, shift the whole list.""" + if nid in y_locked: + delta = new_y - pos[nid][1] + for axis, nodes, gap in list_groups: + if axis == "y" and nid in nodes: + for n in nodes: + pos[n][1] += delta + return + pos[nid][1] = new_y + + +def _compute_sides(src, dst): + """Determine optimal connection sides based on relative positions.""" + src_cx = src["x"] + src["width"] / 2 + src_cy = src["y"] + src["height"] / 2 + dst_cx = dst["x"] + dst["width"] / 2 + dst_cy = dst["y"] + dst["height"] / 2 + + dx = dst_cx - src_cx + dy = dst_cy - src_cy + adx = abs(dx) + ady = abs(dy) + + if adx > ady * 0.7: + return ("right", "left") if dx > 0 else ("left", "right") + if ady > adx * 0.7: + return ("bottom", "top") if dy > 0 else ("top", "bottom") + if dx > 0: + return ("right", "left") + if dy > 0: + return ("bottom", "top") + return ("top", "bottom") + + +def _build_elements(nodes_out, groups_out, edges_out): + """Convert to sdpm elements array.""" + elements = [] + + for _, g in sorted(groups_out.items(), key=lambda x: -x[1]["width"] * x[1]["height"]): + gt = g.get("groupType") + if not gt: + continue + elements.append({ + "type": "arch-group", "groupType": gt, + "x": g["x"], "y": g["y"], + "width": g["width"], "height": g["height"], + "label": g.get("label", ""), + }) + + for nid, n in nodes_out.items(): + if n.get("icon"): + elements.append({ + "type": "image", "src": n["icon"], + "x": n["x"], "y": n["y"], "width": n["width"], + "label": n.get("label", nid), "labelPosition": "bottom", + }) + + for e in edges_out: + pts = e["points"] + if len(pts) < 2: + continue + sx, sy = pts[0] + ex, ey = pts[-1] + + is_detour = (len(pts) >= 4 and pts[0][1] == pts[-1][1] + and any(p[1] != pts[0][1] for p in pts[1:-1])) + if is_detour or len(pts) >= 6: + el = {"type": "line", "arrowEnd": "arrow", + "points": [[p[0], p[1]] for p in pts]} + else: + el = {"type": "line", "x1": sx, "y1": sy, "x2": ex, "y2": ey, "arrowEnd": "arrow"} + if len(pts) > 2: + el["connectorType"] = "elbow" + dx = ex - sx + dy = ey - sy + if len(pts) >= 4 and (abs(dx) > 0 or abs(dy) > 0): + seg1_vert = abs(pts[0][0] - pts[1][0]) <= abs(pts[0][1] - pts[1][1]) + if seg1_vert: + adj = (pts[1][1] - sy) / dy if dy != 0 else 0.5 + el["preset"] = "bentConnector3" + el["elbowStart"] = "vertical" + el["adjustments"] = [max(-2.0, min(3.0, adj))] + else: + adj1 = (pts[1][0] - sx) / dx if dx != 0 else 0.5 + adj2 = (pts[2][1] - sy) / dy if dy != 0 else 0.5 + el["preset"] = "bentConnector4" + el["adjustments"] = [max(-1.0, min(2.0, adj1)), max(-1.0, min(2.0, adj2))] + elif dy != 0 or dx != 0: + el["adjustments"] = [0.5] + + if e.get("lineWidth"): + el["lineWidth"] = e["lineWidth"] + elements.append(el) + + label = e.get("label", "") + if label: + mid_idx = len(pts) // 2 + mx, my = pts[mid_idx] + elements.append({ + "type": "text", "x": mx - 40, "y": my - 25, + "width": 100, "body": label, "fontSize": 10, + }) + + return elements From 59aa1983959830df11da937840e24389ba253a4b Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sat, 27 Jun 2026 22:33:07 +0900 Subject: [PATCH 12/79] fix(layout): prevent arrows from bending backwards through source icon - Clamp bentConnector4 adj1 to minimum 0.15 when direction is forward (prevents H-V-H connector from routing behind the source node) - Fix decided_src_side: only apply consistency override when decided side is on the same axis as the natural side (prevents forcing vertical side on a horizontal connection) - Fix reverse_set: require horizontal displacement > vertical * 0.5 to treat as reverse flow (prevents vertical connections from being routed as U-shaped detours) --- skill/scripts/pptx_builder.py | 5 ++++- skill/sdpm/layout/__init__.py | 29 +++++++++++++++++++---------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/skill/scripts/pptx_builder.py b/skill/scripts/pptx_builder.py index c7842635..bac3ded4 100644 --- a/skill/scripts/pptx_builder.py +++ b/skill/scripts/pptx_builder.py @@ -577,8 +577,11 @@ def build_root(): # H-V-H → bentConnector4 adj1 = (pts[1][0] - sx) / dx if dx != 0 else 0.5 adj2 = (pts[2][1] - sy) / dy if dy != 0 else 0.5 + # Clamp adj1: must go forward from start (min 0.15 = short horizontal stub) + adj1_min = 0.15 if dx > 0 else -1.0 + adj1_max = 2.0 if dx > 0 else -0.15 el["preset"] = "bentConnector4" - el["adjustments"] = [max(-1.0, min(2.0, adj1)), max(-1.0, min(2.0, adj2))] + el["adjustments"] = [max(adj1_min, min(adj1_max, adj1)), max(-1.0, min(2.0, adj2))] elif dy != 0 or dx != 0: el["adjustments"] = [0.5] elements.append(el) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 8c13b089..2a104917 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -601,19 +601,28 @@ def _layout_route_connections(connections, nodes, groups=None): # Consistency: if this source already has a decided side for forward connections, # reuse it to prevent some arrows exiting from a different side (e.g. bottom). - # Skip this override when explicit sides are provided. + # Skip this override when explicit sides are provided, or when the decided side + # is perpendicular to the natural direction (would create a bad route). src_id = conn["from"] if not explicit_src and not explicit_dst: if src_id in decided_src_side: - src_side = decided_src_side[src_id] - if src_side == "right" and dst_side == "top": - dst_side = "left" - elif src_side == "left" and dst_side == "bottom": - dst_side = "right" - elif src_side == "bottom" and dst_side == "right": - dst_side = "top" - elif src_side == "top" and dst_side == "left": - dst_side = "bottom" + decided = decided_src_side[src_id] + # Only apply if decided side is compatible with natural direction + # (same axis: both horizontal or both vertical) + h_sides = {"left", "right"} + v_sides = {"top", "bottom"} + natural_axis = "h" if src_side in h_sides else "v" + decided_axis = "h" if decided in h_sides else "v" + if natural_axis == decided_axis: + src_side = decided + if src_side == "right" and dst_side == "top": + dst_side = "left" + elif src_side == "left" and dst_side == "bottom": + dst_side = "right" + elif src_side == "bottom" and dst_side == "right": + dst_side = "top" + elif src_side == "top" and dst_side == "left": + dst_side = "bottom" else: decided_src_side[src_id] = src_side From c47e48441f803cfb512357a9d9e7804bb125774f Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sat, 27 Jun 2026 22:38:50 +0900 Subject: [PATCH 13/79] fix(layout): correct elbow type detection when points have been shifted When _spread_overlapping_bends shifts intermediate points, the first segment may become diagonal (neither purely vertical nor horizontal). In this case, fall back to using overall dx/dy ratio to determine whether bentConnector3 (V-H-V) or bentConnector4 (H-V-H) is appropriate. --- skill/scripts/pptx_builder.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/skill/scripts/pptx_builder.py b/skill/scripts/pptx_builder.py index bac3ded4..ca79d870 100644 --- a/skill/scripts/pptx_builder.py +++ b/skill/scripts/pptx_builder.py @@ -554,7 +554,12 @@ def build_root(): dy = ey - sy if len(pts) >= 4 and (abs(dx) > 0 or abs(dy) > 0): # 4 points: [start, bend1, bend2, end] + # Determine if first segment is vertical based on points. + # If points have been shifted (non-axis-aligned), fall back to + # overall direction: if |dy| > |dx|, prefer V-H-V (bentConnector3) seg1_vertical = abs(pts[0][0] - pts[1][0]) <= abs(pts[0][1] - pts[1][1]) + if abs(pts[0][0] - pts[1][0]) > 5 and abs(pts[0][1] - pts[1][1]) > 5: + seg1_vertical = abs(dy) > abs(dx) if seg1_vertical: # V-H-V → elbowStart vertical # For U-shaped detour (sy==ty), use the bend Y relative to path height From 351026a1153107c9496b6fdb3b7503044c10e856 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sat, 27 Jun 2026 22:59:13 +0900 Subject: [PATCH 14/79] fix(layout): reduce padding/margin for structural containers without visual groups Containers without groupType or label (purely structural nesting) now use minimal padding/margin (5px) instead of the full group defaults (70px top padding, 20px margin). This prevents excessive spacing between blocks that don't render a visible group box. --- skill/sdpm/layout/__init__.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 2a104917..9beac84e 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -204,8 +204,13 @@ def sv(v): return max(10, round(v * spacing_scale_v)) if is_group: - margin = node.get("margin", {"top": sv(20), "right": sh(20), "bottom": sv(20), "left": sh(20)}) - padding = node.get("padding", {"top": sv(70), "right": sh(30), "bottom": sv(30), "left": sh(30)}) + has_visual_group = node.get("groupType") or node.get("label") + if has_visual_group: + margin = node.get("margin", {"top": sv(20), "right": sh(20), "bottom": sv(20), "left": sh(20)}) + padding = node.get("padding", {"top": sv(70), "right": sh(30), "bottom": sv(30), "left": sh(30)}) + else: + margin = node.get("margin", {"top": sv(5), "right": sh(5), "bottom": sv(5), "left": sh(5)}) + padding = node.get("padding", {"top": sv(5), "right": sh(5), "bottom": sv(5), "left": sh(5)}) else: box = node.get("box") if box: From 0c07349ecd2e5db80372e8507068b9ccbb7a3e6f Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sat, 27 Jun 2026 23:03:08 +0900 Subject: [PATCH 15/79] feat(layout): support targetArea in input JSON for layout positioning LLM can now specify the rendering area directly in the layout JSON: "targetArea": {"x": 960, "y": 120, "width": 880, "height": 880} This allows placing diagrams in a specific region of the slide (e.g. right half for text+diagram layouts). JSON values are used as defaults when CLI args are not provided. --- skill/scripts/pptx_builder.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/skill/scripts/pptx_builder.py b/skill/scripts/pptx_builder.py index ca79d870..0ff2a3b7 100644 --- a/skill/scripts/pptx_builder.py +++ b/skill/scripts/pptx_builder.py @@ -449,6 +449,18 @@ def cmd_layout(args): align = tree.get("align", "center") reverse = tree.get("reverse", False) + # Allow targetArea in JSON to override CLI args + target_area = tree.get("targetArea", {}) + if target_area: + if "x" in target_area and not args.x: + args.x = target_area["x"] + if "y" in target_area and not args.y: + args.y = target_area["y"] + if "width" in target_area and not args.width: + args.width = target_area["width"] + if "height" in target_area and not args.height: + args.height = target_area["height"] + # Pre-process: optimize node order within groups to minimize edge crossings optimize_order(tree) From 3217f5cc11ae18d2e0e64d0736376b5f3c00c2be Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sat, 27 Jun 2026 23:10:22 +0900 Subject: [PATCH 16/79] fix(layout): force consistent side for fan-out connections Fan-out sources (one node connecting to multiple targets) now always use the same exit side for all connections, even when some targets are on a perpendicular axis. This prevents arrows from exiting different sides of the source icon (e.g. one from "right" and another from "top"), which caused visual artifacts and icon overlap. --- skill/sdpm/layout/__init__.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 9beac84e..1ad5d855 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -577,6 +577,19 @@ def _layout_route_connections(connections, nodes, groups=None): # Track decided sides per source node to ensure consistency for fan-out decided_src_side = {} + # Identify fan-out sources (nodes with multiple forward targets). + # For fan-out nodes, all arrows must exit from the same side regardless of + # axis mismatch, so we skip the axis compatibility check for them. + _src_target_count: dict = {} + for idx, conn in enumerate(connections): + if idx in reverse_set: + continue + if conn.get("srcSide") or conn.get("dstSide"): + continue + sid = conn["from"] + _src_target_count[sid] = _src_target_count.get(sid, 0) + 1 + fanout_sources = {sid for sid, cnt in _src_target_count.items() if cnt >= 2} + conn_sides = [] for i, conn in enumerate(connections): src = _find_node(nodes, conn["from"]) @@ -608,17 +621,21 @@ def _layout_route_connections(connections, nodes, groups=None): # reuse it to prevent some arrows exiting from a different side (e.g. bottom). # Skip this override when explicit sides are provided, or when the decided side # is perpendicular to the natural direction (would create a bad route). + # Exception: for fan-out sources (1 source → N targets), ALWAYS apply the + # decided side to keep all arrows exiting from the same edge. src_id = conn["from"] if not explicit_src and not explicit_dst: if src_id in decided_src_side: decided = decided_src_side[src_id] - # Only apply if decided side is compatible with natural direction - # (same axis: both horizontal or both vertical) + # For fan-out sources, always use the decided side. + # For non-fan-out, only apply if same axis (horizontal↔horizontal + # or vertical↔vertical) to avoid bad routes. h_sides = {"left", "right"} v_sides = {"top", "bottom"} natural_axis = "h" if src_side in h_sides else "v" decided_axis = "h" if decided in h_sides else "v" - if natural_axis == decided_axis: + apply_decided = (src_id in fanout_sources) or (natural_axis == decided_axis) + if apply_decided: src_side = decided if src_side == "right" and dst_side == "top": dst_side = "left" From 2298667df95bfecbc791353c2aed60af285010eb Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sat, 27 Jun 2026 23:14:34 +0900 Subject: [PATCH 17/79] fix(layout): pre-decide fan-out exit side by majority vote with right preference Fan-out sources now pre-compute the best exit side before processing connections. The side is chosen by: prefer "right" if any target is to the right, then "left", then majority vote. This prevents the first connection from locking a suboptimal side (e.g. "top") that then gets forced onto all subsequent fan-out connections. --- skill/sdpm/layout/__init__.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 1ad5d855..9f2f74b5 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -578,9 +578,10 @@ def _layout_route_connections(connections, nodes, groups=None): decided_src_side = {} # Identify fan-out sources (nodes with multiple forward targets). - # For fan-out nodes, all arrows must exit from the same side regardless of - # axis mismatch, so we skip the axis compatibility check for them. + # For fan-out nodes, pre-compute the best exit side by majority vote + # of what _auto_sides would choose, preferring horizontal ("right"/"left"). _src_target_count: dict = {} + _src_side_votes: dict = {} # {src_id: {"right": n, "left": n, ...}} for idx, conn in enumerate(connections): if idx in reverse_set: continue @@ -588,8 +589,24 @@ def _layout_route_connections(connections, nodes, groups=None): continue sid = conn["from"] _src_target_count[sid] = _src_target_count.get(sid, 0) + 1 + src = _find_node(nodes, conn["from"]) + dst = _find_node(nodes, conn["to"]) + if src and dst: + s_side, _ = _auto_sides(src, dst, None) + _src_side_votes.setdefault(sid, {}) + _src_side_votes[sid][s_side] = _src_side_votes[sid].get(s_side, 0) + 1 fanout_sources = {sid for sid, cnt in _src_target_count.items() if cnt >= 2} + # Pre-decide side for fan-out sources: prefer "right" > "left" > majority + for sid in fanout_sources: + votes = _src_side_votes.get(sid, {}) + if "right" in votes: + decided_src_side[sid] = "right" + elif "left" in votes: + decided_src_side[sid] = "left" + else: + decided_src_side[sid] = max(votes, key=votes.get) if votes else "right" + conn_sides = [] for i, conn in enumerate(connections): src = _find_node(nodes, conn["from"]) From 080ba1a8ce4ee397c061ed6137b48f99e211e458 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sat, 27 Jun 2026 23:24:35 +0900 Subject: [PATCH 18/79] fix(layout): implement fan-out shared bend pattern and protect from spread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fan-out connections (one source → multiple targets from right side) now use a shared bend X position (45% of distance to nearest target). This creates the "trunk + horizontal branch" pattern instead of independent diagonal elbows. Fan-out edge points are saved before _spread_overlapping_bends and restored after, preventing the crossing/separation logic from breaking the shared bend alignment. --- skill/sdpm/layout/__init__.py | 52 ++++++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 9f2f74b5..4627455f 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -692,6 +692,26 @@ def _layout_route_connections(connections, nodes, groups=None): all_y.append(n["y"] + n["height"]) global_bottom = max(all_y) + 60 if all_y else 500 + # Compute fan-out shared bend X for right-side fan-outs. + # All connections from a fan-out source share the same bend X (midpoint to nearest target). + fanout_bend_x = {} + for sid in fanout_sources: + if decided_src_side.get(sid) == "right": + src_node = _find_node(nodes, sid) + if not src_node: + continue + src_right = src_node["x"] + src_node["width"] + # Find nearest target left edge + target_lefts = [] + for idx, conn in enumerate(connections): + if conn["from"] == sid and idx not in reverse_set: + dst_node = _find_node(nodes, conn["to"]) + if dst_node: + target_lefts.append(dst_node["x"]) + if target_lefts: + nearest_left = min(target_lefts) + fanout_bend_x[sid] = src_right + (nearest_left - src_right) * 0.45 + edges = [] for i, conn in enumerate(connections): src, dst, src_side, dst_side = conn_sides[i] @@ -699,6 +719,7 @@ def _layout_route_connections(connections, nodes, groups=None): edges.append({"from": conn["from"], "to": conn["to"], "label": conn.get("label", ""), "points": []}) continue + is_fanout_edge = False if i in reverse_set: src_node = _find_node(nodes, conn["from"]) dst_node = _find_node(nodes, conn["to"]) @@ -711,15 +732,36 @@ def _layout_route_connections(connections, nodes, groups=None): label_h = 30 if src.get("label") else 0 sp = _port_point(src, src_side, port_indices[(i, conn["from"])], port_counts[(conn["from"], src_side)], label_h) tp = _port_point(dst, dst_side, port_indices[(i, conn["to"])], port_counts[(conn["to"], dst_side)], label_h) - points = _elbow_path(sp, tp, src_side, dst_side, obstacles) - edges.append({"from": conn["from"], "to": conn["to"], "label": conn.get("label", ""), "points": points}) + + # Fan-out right-side: use shared bend X for H-V-H pattern + # Skip if already a straight line (same Y) + src_id = conn["from"] + is_fanout_edge = False + if src_id in fanout_bend_x and src_side == "right" and abs(sp[1] - tp[1]) > 5: + bend_x = round(fanout_bend_x[src_id]) + points = [sp, [bend_x, sp[1]], [bend_x, tp[1]], tp] + is_fanout_edge = True + else: + points = _elbow_path(sp, tp, src_side, dst_side, obstacles) + edge_entry = {"from": conn["from"], "to": conn["to"], "label": conn.get("label", ""), "points": points} + if is_fanout_edge: + edge_entry["_fanout"] = True + edges.append(edge_entry) # T8: Align bend positions for fan-out/fan-in only when "fan": "merge" is set _align_fan_bends(edges, conn_sides, connections) + # Save fan-out edge points (they should not be modified by spread/crossing logic) + import copy + fanout_saved = {i: copy.deepcopy(e["points"]) for i, e in enumerate(edges) if e.get("_fanout")} + # T9: Spread overlapping elbow bends from the same source _spread_overlapping_bends(edges, conn_sides, connections) + # Restore fan-out edge points + for i, pts in fanout_saved.items(): + edges[i]["points"] = pts + return edges @@ -942,11 +984,11 @@ def _find_first_crossing(edges): """Find the first pair of crossing segments across all edges.""" for i in range(len(edges)): pts_i = edges[i]["points"] - if len(pts_i) < 2: + if len(pts_i) < 2 or edges[i].get("_fanout"): continue for j in range(i + 1, len(edges)): pts_j = edges[j]["points"] - if len(pts_j) < 2: + if len(pts_j) < 2 or edges[j].get("_fanout"): continue for si in range(len(pts_i) - 1): for sj in range(len(pts_j) - 1): @@ -1090,6 +1132,8 @@ def _separate_close_bends(edges): # Collect all vertical bend segments: (edge_idx, seg_idx, x, y_min, y_max) v_bends = [] for ei, e in enumerate(edges): + if e.get("_fanout"): + continue pts = e["points"] for k in range(len(pts) - 1): if pts[k][0] == pts[k + 1][0] and abs(pts[k][1] - pts[k + 1][1]) > 10: From 78aad24c8cd9817ff128b8549203738e97ca4294 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sat, 27 Jun 2026 23:31:14 +0900 Subject: [PATCH 19/79] fix(layout): correct fan-out dst_side and render as polyline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fan-out connections now force dst_side to the opposite of src_side (e.g. src=right → dst=left) instead of keeping the natural auto_sides result that may point to wrong edge (bottom instead of left) - Fan-out edges with 4+ points are rendered as polyline (freeform) instead of bentConnector4, avoiding adjustment calculation errors that caused arrows to miss target icons --- skill/scripts/pptx_builder.py | 5 +++-- skill/sdpm/layout/__init__.py | 27 +++++++++++++++++++-------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/skill/scripts/pptx_builder.py b/skill/scripts/pptx_builder.py index 0ff2a3b7..1a3d93fe 100644 --- a/skill/scripts/pptx_builder.py +++ b/skill/scripts/pptx_builder.py @@ -552,9 +552,10 @@ def build_root(): sx, sy = pts[0] ex, ey = pts[-1] - # Detour paths (4+ points, U-shape) or complex routes: emit as polyline + # Detour paths (4+ points, U-shape), fan-out paths, or complex routes: emit as polyline is_detour = len(pts) >= 4 and pts[0][1] == pts[-1][1] and any(p[1] != pts[0][1] for p in pts[1:-1]) - if is_detour or len(pts) >= 6: + is_fanout = e.get("_fanout", False) + if is_detour or is_fanout or len(pts) >= 6: el = {"type": "line", "arrowEnd": "arrow", "points": [[p[0], p[1]] for p in pts]} elements.append(el) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 4627455f..444e273f 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -654,14 +654,25 @@ def _layout_route_connections(connections, nodes, groups=None): apply_decided = (src_id in fanout_sources) or (natural_axis == decided_axis) if apply_decided: src_side = decided - if src_side == "right" and dst_side == "top": - dst_side = "left" - elif src_side == "left" and dst_side == "bottom": - dst_side = "right" - elif src_side == "bottom" and dst_side == "right": - dst_side = "top" - elif src_side == "top" and dst_side == "left": - dst_side = "bottom" + # Fix dst_side to be the opposite receiving side + if src_id in fanout_sources: + if src_side == "right": + dst_side = "left" + elif src_side == "left": + dst_side = "right" + elif src_side == "bottom": + dst_side = "top" + elif src_side == "top": + dst_side = "bottom" + else: + if src_side == "right" and dst_side == "top": + dst_side = "left" + elif src_side == "left" and dst_side == "bottom": + dst_side = "right" + elif src_side == "bottom" and dst_side == "right": + dst_side = "top" + elif src_side == "top" and dst_side == "left": + dst_side = "bottom" else: decided_src_side[src_id] = src_side From d9b7d3593212760e88b611a663a24626139a867e Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sat, 27 Jun 2026 23:34:43 +0900 Subject: [PATCH 20/79] fix(layout): add node icons as obstacles and fix _calc_bend to avoid interiors - All nodes are now added to the obstacles list (with _node marker to exclude src/dst from their own connection's obstacle check) - _calc_bend now detects when bend position falls inside an obstacle (not just near its edge) and shifts to the nearest outside edge - Connections exclude their own src/dst nodes from obstacle avoidance to prevent self-interference --- skill/sdpm/layout/__init__.py | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 444e273f..a67f18a8 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -553,6 +553,9 @@ def _layout_route_connections(connections, nodes, groups=None): for cid in g.get("children", []): node_group[cid] = gid obstacles = [{"x": g["x"], "y": g["y"], "width": g["width"], "height": g["height"]} for g in groups.values()] + # Also add all nodes as obstacles so arrows avoid passing through icons + for nid, n in nodes.items(): + obstacles.append({"x": n["x"], "y": n["y"], "width": n.get("width", 60), "height": n.get("height", 60), "_node": nid}) port_counts = {} port_indices = {} @@ -753,7 +756,9 @@ def _layout_route_connections(connections, nodes, groups=None): points = [sp, [bend_x, sp[1]], [bend_x, tp[1]], tp] is_fanout_edge = True else: - points = _elbow_path(sp, tp, src_side, dst_side, obstacles) + # Exclude src/dst nodes from obstacles to avoid self-avoidance + conn_obs = [o for o in obstacles if o.get("_node") not in (conn["from"], conn["to"])] + points = _elbow_path(sp, tp, src_side, dst_side, conn_obs) edge_entry = {"from": conn["from"], "to": conn["to"], "label": conn.get("label", ""), "points": points} if is_fanout_edge: edge_entry["_fanout"] = True @@ -1573,18 +1578,24 @@ def _port_point(node, side, index, count, label_h): def _calc_bend(val, lo, hi, obstacles, axis): - """Calculate bend position avoiding obstacle boundaries.""" + """Calculate bend position avoiding obstacle boundaries and interiors.""" val = max(val, lo + MIN_BEND_MARGIN) val = min(val, hi - MIN_BEND_MARGIN) for obs in obstacles: if axis == "x": - edge_lo, edge_hi = obs["x"], obs["x"] + obs["width"] + edge_lo, edge_hi = obs["x"] - OBSTACLE_MARGIN, obs["x"] + obs["width"] + OBSTACLE_MARGIN else: - edge_lo, edge_hi = obs["y"], obs["y"] + obs["height"] - if abs(val - edge_lo) <= OBSTACLE_MARGIN: - val = edge_lo - OBSTACLE_MARGIN - 5 - elif abs(val - edge_hi) <= OBSTACLE_MARGIN: - val = edge_hi + OBSTACLE_MARGIN + 5 + edge_lo, edge_hi = obs["y"] - OBSTACLE_MARGIN, obs["y"] + obs["height"] + OBSTACLE_MARGIN + if edge_lo < val < edge_hi: + # Bend is inside obstacle — move to nearest edge outside + dist_to_lo = val - edge_lo + dist_to_hi = edge_hi - val + if dist_to_lo <= dist_to_hi: + val = edge_lo - 5 + else: + val = edge_hi + 5 + val = max(val, lo + MIN_BEND_MARGIN) + val = min(val, hi - MIN_BEND_MARGIN) return val From 7e487c8f5cc9e558e75ae5487b930509afa7df26 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sun, 28 Jun 2026 00:20:29 +0900 Subject: [PATCH 21/79] fix(layout): render all 4+ point paths as polyline for precise routing bentConnector4 adjustment calculations introduce errors that cause arrows to appear non-perpendicular to icon edges. All paths with 4+ points are now rendered as polylines (freeform shapes) which exactly follow the routing engine's computed points. --- skill/scripts/pptx_builder.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/skill/scripts/pptx_builder.py b/skill/scripts/pptx_builder.py index 1a3d93fe..a9ef36c1 100644 --- a/skill/scripts/pptx_builder.py +++ b/skill/scripts/pptx_builder.py @@ -552,10 +552,11 @@ def build_root(): sx, sy = pts[0] ex, ey = pts[-1] - # Detour paths (4+ points, U-shape), fan-out paths, or complex routes: emit as polyline + # Emit as polyline for: 4+ point paths (precise routing), fan-out, detours, complex is_detour = len(pts) >= 4 and pts[0][1] == pts[-1][1] and any(p[1] != pts[0][1] for p in pts[1:-1]) is_fanout = e.get("_fanout", False) - if is_detour or is_fanout or len(pts) >= 6: + is_multipoint = len(pts) >= 4 + if is_detour or is_fanout or is_multipoint or len(pts) >= 6: el = {"type": "line", "arrowEnd": "arrow", "points": [[p[0], p[1]] for p in pts]} elements.append(el) From c9bad5d99391834b21f0bb2d86af90173896560c Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sun, 28 Jun 2026 00:25:52 +0900 Subject: [PATCH 22/79] fix(layout): post-process bends to avoid passing through node icons After _spread_overlapping_bends may shift bend positions into node areas, a new _fix_bends_inside_nodes pass checks intermediate segments and shifts any vertical/horizontal segment that passes through a non-src/dst node's bounding box to the nearest outside edge. Only intermediate segments are checked (start/end points are anchored to ports). --- skill/sdpm/layout/__init__.py | 57 +++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index a67f18a8..24b3ecf5 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -778,6 +778,9 @@ def _layout_route_connections(connections, nodes, groups=None): for i, pts in fanout_saved.items(): edges[i]["points"] = pts + # Post-process: fix bends that ended up inside node icons after spread + _fix_bends_inside_nodes(edges, nodes, connections) + return edges @@ -1572,6 +1575,60 @@ def _port_point(node, side, index, count, label_h): return [round(x + w * t), y] +def _fix_bends_inside_nodes(edges, nodes, connections): + """Post-process: fix bends that pass through or graze node icons. + + Checks intermediate points AND segments between them. If a vertical + segment at x=N would pass through a node's x-range and y-range, + shift the bend X to avoid it. + """ + margin = 15 + for ei, e in enumerate(edges): + pts = e["points"] + if len(pts) < 3: + continue + src_id = e.get("from", "") + dst_id = e.get("to", "") + for nid, n in nodes.items(): + if nid == src_id or nid == dst_id: + continue + nx, ny = n["x"], n["y"] + nw = n.get("width", 60) + nh = n.get("height", 60) + # Check intermediate segments only (skip first and last which touch src/dst) + for k in range(1, len(pts) - 2): + p1 = pts[k] + p2 = pts[k + 1] + # Vertical segment: same X, check if it passes through node + if abs(p1[0] - p2[0]) < 3: + seg_x = p1[0] + seg_y_lo = min(p1[1], p2[1]) + seg_y_hi = max(p1[1], p2[1]) + if (nx - margin < seg_x < nx + nw + margin and + seg_y_lo < ny + nh + margin and seg_y_hi > ny - margin): + # Vertical segment passes through node + new_x = nx - margin - 5 + if k > 0 and pts[k][0] == seg_x: + pts[k] = [new_x, pts[k][1]] + if k + 1 < len(pts) - 1 and pts[k+1][0] == seg_x: + pts[k+1] = [new_x, pts[k+1][1]] + break + # Horizontal segment: same Y, check if it passes through node + elif abs(p1[1] - p2[1]) < 3: + seg_y = p1[1] + seg_x_lo = min(p1[0], p2[0]) + seg_x_hi = max(p1[0], p2[0]) + if (ny - margin < seg_y < ny + nh + margin and + seg_x_lo < nx + nw + margin and seg_x_hi > nx - margin): + # Horizontal segment passes through node + new_y = ny - margin - 5 + if k > 0 and pts[k][1] == seg_y: + pts[k] = [pts[k][0], new_y] + if k + 1 < len(pts) - 1 and pts[k+1][1] == seg_y: + pts[k+1] = [pts[k+1][0], new_y] + break + + SNAP_THRESHOLD = 5 MIN_BEND_MARGIN = 20 OBSTACLE_MARGIN = 10 From f82de1b118f16758cb6cba369973f735a4b4125f Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sun, 28 Jun 2026 00:49:40 +0900 Subject: [PATCH 23/79] fix(layout): eliminate all diagonal segments in arrow paths Added final pass that snaps any remaining diagonal segments to axis-aligned. After _spread_overlapping_bends and _fix_bends_inside_nodes may introduce diagonal segments by shifting intermediate points, this pass forces all segments to be either horizontal or vertical by snapping coordinates to match adjacent points. --- skill/sdpm/layout/__init__.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 24b3ecf5..bf89f4a6 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -781,6 +781,27 @@ def _layout_route_connections(connections, nodes, groups=None): # Post-process: fix bends that ended up inside node icons after spread _fix_bends_inside_nodes(edges, nodes, connections) + # Final pass: eliminate any diagonal segments by snapping to axis-aligned + for e in edges: + pts = e["points"] + for k in range(1, len(pts)): + dx = abs(pts[k][0] - pts[k-1][0]) + dy = abs(pts[k][1] - pts[k-1][1]) + if dx > 3 and dy > 3: + # Diagonal — snap to match previous point's axis + if k == 1: + # First segment: keep start X, snap pts[1] X to match + pts[k] = [pts[k-1][0], pts[k][1]] + elif k == len(pts) - 1: + # Last segment: keep end Y, snap pts[-2] Y to match + pts[k-1] = [pts[k-1][0], pts[k][1]] + else: + # Middle: snap to nearest axis + if dx < dy: + pts[k] = [pts[k-1][0], pts[k][1]] + else: + pts[k] = [pts[k][0], pts[k-1][1]] + return edges @@ -1595,8 +1616,13 @@ def _fix_bends_inside_nodes(edges, nodes, connections): nx, ny = n["x"], n["y"] nw = n.get("width", 60) nh = n.get("height", 60) - # Check intermediate segments only (skip first and last which touch src/dst) + # Check intermediate segments only (skip segments touching start/end points) for k in range(1, len(pts) - 2): + # Skip if this point is adjacent to start/end and shares an axis + if k == 1 and (pts[0][0] == pts[1][0] or pts[0][1] == pts[1][1]): + continue + if k == len(pts) - 3 and (pts[-1][0] == pts[-2][0] or pts[-1][1] == pts[-2][1]): + continue p1 = pts[k] p2 = pts[k + 1] # Vertical segment: same X, check if it passes through node From 333bba5b7188453b9764dbb08fe90f4788332f0e Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sun, 28 Jun 2026 01:05:52 +0900 Subject: [PATCH 24/79] fix(layout): prevent arrows from going backwards through source/target icons Added final pass that ensures: - Arrows exiting from "right" side never bend left of their start point - Arrows entering from "left" side never approach from the right - Same for top/bottom variants This prevents the pattern where _spread_overlapping_bends shifts a bend to the wrong side of the source/target node, causing the arrow to pass through its own icon. --- skill/sdpm/layout/__init__.py | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index bf89f4a6..d0ce8b4a 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -781,6 +781,34 @@ def _layout_route_connections(connections, nodes, groups=None): # Post-process: fix bends that ended up inside node icons after spread _fix_bends_inside_nodes(edges, nodes, connections) + # Final pass: fix arrows going backwards from their start/end ports + for ei, e in enumerate(edges): + pts = e["points"] + if len(pts) < 3: + continue + i_conn = ei if ei < len(conn_sides) else 0 + _, _, src_side, dst_side = conn_sides[i_conn] if i_conn < len(conn_sides) else (None, None, None, None) + + # If src_side is "right" but pts[1].x < pts[0].x, snap pts[1].x to pts[0].x + if src_side == "right" and pts[1][0] < pts[0][0]: + pts[1] = [pts[0][0], pts[1][1]] + elif src_side == "left" and pts[1][0] > pts[0][0]: + pts[1] = [pts[0][0], pts[1][1]] + elif src_side == "bottom" and pts[1][1] < pts[0][1]: + pts[1] = [pts[1][0], pts[0][1]] + elif src_side == "top" and pts[1][1] > pts[0][1]: + pts[1] = [pts[1][0], pts[0][1]] + + # If dst_side is "left" but pts[-2].x > pts[-1].x, snap + if dst_side == "left" and len(pts) >= 3 and pts[-2][0] > pts[-1][0]: + pts[-2] = [pts[-1][0], pts[-2][1]] + elif dst_side == "right" and len(pts) >= 3 and pts[-2][0] < pts[-1][0]: + pts[-2] = [pts[-1][0], pts[-2][1]] + elif dst_side == "top" and len(pts) >= 3 and pts[-2][1] > pts[-1][1]: + pts[-2] = [pts[-2][0], pts[-1][1]] + elif dst_side == "bottom" and len(pts) >= 3 and pts[-2][1] < pts[-1][1]: + pts[-2] = [pts[-2][0], pts[-1][1]] + # Final pass: eliminate any diagonal segments by snapping to axis-aligned for e in edges: pts = e["points"] @@ -788,15 +816,11 @@ def _layout_route_connections(connections, nodes, groups=None): dx = abs(pts[k][0] - pts[k-1][0]) dy = abs(pts[k][1] - pts[k-1][1]) if dx > 3 and dy > 3: - # Diagonal — snap to match previous point's axis if k == 1: - # First segment: keep start X, snap pts[1] X to match pts[k] = [pts[k-1][0], pts[k][1]] elif k == len(pts) - 1: - # Last segment: keep end Y, snap pts[-2] Y to match pts[k-1] = [pts[k-1][0], pts[k][1]] else: - # Middle: snap to nearest axis if dx < dy: pts[k] = [pts[k-1][0], pts[k][1]] else: From 9ad701405b2155485b177a6fbd170a2950ba86ba Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sun, 28 Jun 2026 01:13:51 +0900 Subject: [PATCH 25/79] fix(layout): fix fan-out bend_x calculation for targets to the left When computing fanout_bend_x, only consider targets whose left edge is to the right of the source's right edge. If all targets are to the left (e.g. Planner is below and at same X as Step Functions), use a fixed offset (src_right + 30) to ensure the bend is always forward from the source exit point. --- skill/sdpm/layout/__init__.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index d0ce8b4a..6cdc98fd 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -722,9 +722,14 @@ def _layout_route_connections(connections, nodes, groups=None): dst_node = _find_node(nodes, conn["to"]) if dst_node: target_lefts.append(dst_node["x"]) - if target_lefts: - nearest_left = min(target_lefts) + # Only consider targets to the right of source + right_targets = [x for x in target_lefts if x > src_right] + if right_targets: + nearest_left = min(right_targets) fanout_bend_x[sid] = src_right + (nearest_left - src_right) * 0.45 + elif target_lefts: + # All targets are to the left or same X — use a fixed offset + fanout_bend_x[sid] = src_right + 30 edges = [] for i, conn in enumerate(connections): From e38df0df724e104b3bfcf9736982e1a679c5621b Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sun, 28 Jun 2026 01:18:11 +0900 Subject: [PATCH 26/79] fix(layout): use natural dst_side for fan-out targets directly above/below When a fan-out target is mostly above/below the source (vertical distance > 2x horizontal distance), use the natural _auto_sides dst_side instead of forcing "left". This prevents the fan-out H-V-H path from crossing through the target icon when it's at the same X as the source. Also restrict fan-out shared bend path to only apply when dst_side="left" (horizontal entry), falling back to normal _elbow_path for vertical entry. --- skill/sdpm/layout/__init__.py | 43 +++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 6cdc98fd..4ee23eea 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -657,16 +657,39 @@ def _layout_route_connections(connections, nodes, groups=None): apply_decided = (src_id in fanout_sources) or (natural_axis == decided_axis) if apply_decided: src_side = decided - # Fix dst_side to be the opposite receiving side + # Fix dst_side for fan-out: use opposite side, but if dst + # is directly above/below src (not to the side), keep natural dst_side if src_id in fanout_sources: - if src_side == "right": - dst_side = "left" - elif src_side == "left": - dst_side = "right" - elif src_side == "bottom": - dst_side = "top" - elif src_side == "top": - dst_side = "bottom" + if src and dst: + src_cx = src["x"] + src.get("width", 60) / 2 + dst_cx = dst["x"] + dst.get("width", 60) / 2 + src_cy = src["y"] + src.get("height", 60) / 2 + dst_cy = dst["y"] + dst.get("height", 60) / 2 + adx = abs(dst_cx - src_cx) + ady = abs(dst_cy - src_cy) + if ady > adx * 2: + # Target is mostly above/below — use natural dst_side + _, natural_dst = _auto_sides(src, dst, None) + dst_side = natural_dst + else: + # Target is to the side — use opposite + if src_side == "right": + dst_side = "left" + elif src_side == "left": + dst_side = "right" + elif src_side == "bottom": + dst_side = "top" + elif src_side == "top": + dst_side = "bottom" + else: + if src_side == "right": + dst_side = "left" + elif src_side == "left": + dst_side = "right" + elif src_side == "bottom": + dst_side = "top" + elif src_side == "top": + dst_side = "bottom" else: if src_side == "right" and dst_side == "top": dst_side = "left" @@ -756,7 +779,7 @@ def _layout_route_connections(connections, nodes, groups=None): # Skip if already a straight line (same Y) src_id = conn["from"] is_fanout_edge = False - if src_id in fanout_bend_x and src_side == "right" and abs(sp[1] - tp[1]) > 5: + if src_id in fanout_bend_x and src_side == "right" and abs(sp[1] - tp[1]) > 5 and dst_side == "left": bend_x = round(fanout_bend_x[src_id]) points = [sp, [bend_x, sp[1]], [bend_x, tp[1]], tp] is_fanout_edge = True From 3949e421bf379094334b1f841d0f599dee236cc6 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sun, 28 Jun 2026 01:23:26 +0900 Subject: [PATCH 27/79] fix(layout): render 3+ point paths as polyline and iterate diagonal fix - All paths with 3+ points are now rendered as polylines for precision - Diagonal elimination pass now runs up to 5 iterations to handle cascading fixes where fixing one segment creates another diagonal - Fixed fan-out bend_x to only consider targets to the right of source --- skill/scripts/pptx_builder.py | 4 ++-- skill/sdpm/layout/__init__.py | 32 +++++++++++++++++++------------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/skill/scripts/pptx_builder.py b/skill/scripts/pptx_builder.py index a9ef36c1..7f0bc4aa 100644 --- a/skill/scripts/pptx_builder.py +++ b/skill/scripts/pptx_builder.py @@ -552,10 +552,10 @@ def build_root(): sx, sy = pts[0] ex, ey = pts[-1] - # Emit as polyline for: 4+ point paths (precise routing), fan-out, detours, complex + # Emit as polyline for: 3+ point paths (precise routing), fan-out, detours, complex is_detour = len(pts) >= 4 and pts[0][1] == pts[-1][1] and any(p[1] != pts[0][1] for p in pts[1:-1]) is_fanout = e.get("_fanout", False) - is_multipoint = len(pts) >= 4 + is_multipoint = len(pts) >= 3 if is_detour or is_fanout or is_multipoint or len(pts) >= 6: el = {"type": "line", "arrowEnd": "arrow", "points": [[p[0], p[1]] for p in pts]} diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 4ee23eea..175ebff8 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -838,21 +838,27 @@ def _layout_route_connections(connections, nodes, groups=None): pts[-2] = [pts[-2][0], pts[-1][1]] # Final pass: eliminate any diagonal segments by snapping to axis-aligned - for e in edges: - pts = e["points"] - for k in range(1, len(pts)): - dx = abs(pts[k][0] - pts[k-1][0]) - dy = abs(pts[k][1] - pts[k-1][1]) - if dx > 3 and dy > 3: - if k == 1: - pts[k] = [pts[k-1][0], pts[k][1]] - elif k == len(pts) - 1: - pts[k-1] = [pts[k-1][0], pts[k][1]] - else: - if dx < dy: + # Run multiple iterations since fixing one segment may create another + for _pass in range(5): + any_fixed = False + for e in edges: + pts = e["points"] + for k in range(1, len(pts)): + dx = abs(pts[k][0] - pts[k-1][0]) + dy = abs(pts[k][1] - pts[k-1][1]) + if dx > 3 and dy > 3: + any_fixed = True + if k == 1: pts[k] = [pts[k-1][0], pts[k][1]] + elif k == len(pts) - 1: + pts[k-1] = [pts[k-1][0], pts[k][1]] else: - pts[k] = [pts[k][0], pts[k-1][1]] + if dx < dy: + pts[k] = [pts[k-1][0], pts[k][1]] + else: + pts[k] = [pts[k][0], pts[k-1][1]] + if not any_fixed: + break return edges From 5f89f50b8c779d6c3dbb026f5ef23822f0aff4d6 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sun, 28 Jun 2026 01:27:16 +0900 Subject: [PATCH 28/79] fix(layout): guarantee all arrow segments are axis-aligned Replace the iterative snap-based diagonal elimination (which could oscillate forever) with a deterministic point-insertion approach: for any diagonal segment, insert an intermediate point that creates an L-shaped (horizontal then vertical) connection. This guarantees zero diagonal segments in a single pass without oscillation. Also improved _fix_bends_inside_nodes to maintain axis-alignment of adjacent segments when shifting points. --- skill/sdpm/layout/__init__.py | 66 +++++++++++++++-------------------- 1 file changed, 28 insertions(+), 38 deletions(-) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 175ebff8..df78cf55 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -837,28 +837,18 @@ def _layout_route_connections(connections, nodes, groups=None): elif dst_side == "bottom" and len(pts) >= 3 and pts[-2][1] < pts[-1][1]: pts[-2] = [pts[-2][0], pts[-1][1]] - # Final pass: eliminate any diagonal segments by snapping to axis-aligned - # Run multiple iterations since fixing one segment may create another - for _pass in range(5): - any_fixed = False - for e in edges: - pts = e["points"] - for k in range(1, len(pts)): - dx = abs(pts[k][0] - pts[k-1][0]) - dy = abs(pts[k][1] - pts[k-1][1]) - if dx > 3 and dy > 3: - any_fixed = True - if k == 1: - pts[k] = [pts[k-1][0], pts[k][1]] - elif k == len(pts) - 1: - pts[k-1] = [pts[k-1][0], pts[k][1]] - else: - if dx < dy: - pts[k] = [pts[k-1][0], pts[k][1]] - else: - pts[k] = [pts[k][0], pts[k-1][1]] - if not any_fixed: - break + # Final pass: eliminate diagonal segments by inserting intermediate points + for e in edges: + new_pts = [e["points"][0]] + pts = e["points"] + for k in range(1, len(pts)): + dx = abs(pts[k][0] - new_pts[-1][0]) + dy = abs(pts[k][1] - new_pts[-1][1]) + if dx > 3 and dy > 3: + # Insert an L-shaped intermediate: go horizontal first, then vertical + new_pts.append([pts[k][0], new_pts[-1][1]]) + new_pts.append(pts[k]) + e["points"] = new_pts return edges @@ -1674,13 +1664,8 @@ def _fix_bends_inside_nodes(edges, nodes, connections): nx, ny = n["x"], n["y"] nw = n.get("width", 60) nh = n.get("height", 60) - # Check intermediate segments only (skip segments touching start/end points) + # Check intermediate segments (between first and last segments) for k in range(1, len(pts) - 2): - # Skip if this point is adjacent to start/end and shares an axis - if k == 1 and (pts[0][0] == pts[1][0] or pts[0][1] == pts[1][1]): - continue - if k == len(pts) - 3 and (pts[-1][0] == pts[-2][0] or pts[-1][1] == pts[-2][1]): - continue p1 = pts[k] p2 = pts[k + 1] # Vertical segment: same X, check if it passes through node @@ -1690,12 +1675,15 @@ def _fix_bends_inside_nodes(edges, nodes, connections): seg_y_hi = max(p1[1], p2[1]) if (nx - margin < seg_x < nx + nw + margin and seg_y_lo < ny + nh + margin and seg_y_hi > ny - margin): - # Vertical segment passes through node new_x = nx - margin - 5 - if k > 0 and pts[k][0] == seg_x: - pts[k] = [new_x, pts[k][1]] - if k + 1 < len(pts) - 1 and pts[k+1][0] == seg_x: - pts[k+1] = [new_x, pts[k+1][1]] + # Shift both points of this vertical segment + pts[k] = [new_x, pts[k][1]] + pts[k+1] = [new_x, pts[k+1][1]] + # Also fix the adjacent horizontal segments to stay connected + if k > 0 and abs(pts[k-1][1] - pts[k][1]) < 3: + pts[k-1] = [pts[k-1][0], pts[k][1]] + if k + 2 < len(pts) and abs(pts[k+1][1] - pts[k+2][1]) < 3: + pts[k+2] = [pts[k+2][0], pts[k+1][1]] break # Horizontal segment: same Y, check if it passes through node elif abs(p1[1] - p2[1]) < 3: @@ -1704,12 +1692,14 @@ def _fix_bends_inside_nodes(edges, nodes, connections): seg_x_hi = max(p1[0], p2[0]) if (ny - margin < seg_y < ny + nh + margin and seg_x_lo < nx + nw + margin and seg_x_hi > nx - margin): - # Horizontal segment passes through node new_y = ny - margin - 5 - if k > 0 and pts[k][1] == seg_y: - pts[k] = [pts[k][0], new_y] - if k + 1 < len(pts) - 1 and pts[k+1][1] == seg_y: - pts[k+1] = [pts[k+1][0], new_y] + pts[k] = [pts[k][0], new_y] + pts[k+1] = [pts[k+1][0], new_y] + # Fix adjacent vertical segments + if k > 0 and abs(pts[k-1][0] - pts[k][0]) < 3: + pts[k-1] = [pts[k][0], pts[k-1][1]] + if k + 2 < len(pts) and abs(pts[k+1][0] - pts[k+2][0]) < 3: + pts[k+2] = [pts[k+1][0], pts[k+2][1]] break From 97514bf3f86c8df54755954d731ca1db60921c41 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sun, 28 Jun 2026 01:30:46 +0900 Subject: [PATCH 29/79] fix(layout): revert to natural src_side for fan-out targets directly below MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a fan-out target is mostly above/below the source (ady > adx * 2), both src_side and dst_side now revert to natural _auto_sides values. This allows SF→Planner (directly below) to use bottom→top as a straight vertical line instead of the forced right→left fan-out pattern. --- skill/sdpm/layout/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index df78cf55..2d315d82 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -668,8 +668,9 @@ def _layout_route_connections(connections, nodes, groups=None): adx = abs(dst_cx - src_cx) ady = abs(dst_cy - src_cy) if ady > adx * 2: - # Target is mostly above/below — use natural dst_side - _, natural_dst = _auto_sides(src, dst, None) + # Target is mostly above/below — use natural sides + natural_src, natural_dst = _auto_sides(src, dst, None) + src_side = natural_src dst_side = natural_dst else: # Target is to the side — use opposite From 0f550269afe0146b94b80d11cacce5ad0ec48316 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sun, 28 Jun 2026 01:34:55 +0900 Subject: [PATCH 30/79] fix(layout): improve backwards detection using closest-edge heuristic Replace fixed-threshold side detection with closest-edge calculation for backwards arrow fix. Also handle edge case where top-side port has a horizontal backwards segment. --- skill/sdpm/layout/__init__.py | 80 +++++++++++++++++++++++++---------- 1 file changed, 57 insertions(+), 23 deletions(-) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 2d315d82..04c442fd 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -810,33 +810,67 @@ def _layout_route_connections(connections, nodes, groups=None): # Post-process: fix bends that ended up inside node icons after spread _fix_bends_inside_nodes(edges, nodes, connections) - # Final pass: fix arrows going backwards from their start/end ports + # Final pass: fix arrows going backwards from their start/end ports. + # Detect side from the port position relative to the source/destination node. for ei, e in enumerate(edges): pts = e["points"] if len(pts) < 3: continue - i_conn = ei if ei < len(conn_sides) else 0 - _, _, src_side, dst_side = conn_sides[i_conn] if i_conn < len(conn_sides) else (None, None, None, None) - - # If src_side is "right" but pts[1].x < pts[0].x, snap pts[1].x to pts[0].x - if src_side == "right" and pts[1][0] < pts[0][0]: - pts[1] = [pts[0][0], pts[1][1]] - elif src_side == "left" and pts[1][0] > pts[0][0]: - pts[1] = [pts[0][0], pts[1][1]] - elif src_side == "bottom" and pts[1][1] < pts[0][1]: - pts[1] = [pts[1][0], pts[0][1]] - elif src_side == "top" and pts[1][1] > pts[0][1]: - pts[1] = [pts[1][0], pts[0][1]] - - # If dst_side is "left" but pts[-2].x > pts[-1].x, snap - if dst_side == "left" and len(pts) >= 3 and pts[-2][0] > pts[-1][0]: - pts[-2] = [pts[-1][0], pts[-2][1]] - elif dst_side == "right" and len(pts) >= 3 and pts[-2][0] < pts[-1][0]: - pts[-2] = [pts[-1][0], pts[-2][1]] - elif dst_side == "top" and len(pts) >= 3 and pts[-2][1] > pts[-1][1]: - pts[-2] = [pts[-2][0], pts[-1][1]] - elif dst_side == "bottom" and len(pts) >= 3 and pts[-2][1] < pts[-1][1]: - pts[-2] = [pts[-2][0], pts[-1][1]] + src_node = _find_node(nodes, e["from"]) + dst_node = _find_node(nodes, e["to"]) + + # Determine src_side from start point position on node edge + if src_node: + sx, sy = pts[0] + nx, ny, nw, nh = src_node["x"], src_node["y"], src_node.get("width", 60), src_node.get("height", 60) + # Check which edge the start point is closest to + dist_right = abs(sx - (nx + nw)) + dist_left = abs(sx - nx) + dist_bottom = abs(sy - (ny + nh)) + dist_top = abs(sy - ny) + min_dist = min(dist_right, dist_left, dist_bottom, dist_top) + if min_dist == dist_right: + src_side = "right" + elif min_dist == dist_left: + src_side = "left" + elif min_dist == dist_bottom: + src_side = "bottom" + else: + src_side = "top" + + if src_side == "right" and pts[1][0] < pts[0][0]: + pts[1] = [pts[0][0], pts[1][1]] + elif src_side == "left" and pts[1][0] > pts[0][0]: + pts[1] = [pts[0][0], pts[1][1]] + elif src_side == "bottom" and pts[1][1] < pts[0][1]: + pts[1] = [pts[1][0], pts[0][1]] + elif src_side == "top" and pts[1][1] > pts[0][1]: + pts[1] = [pts[1][0], pts[0][1]] + elif src_side == "top" and abs(pts[1][1] - pts[0][1]) < 3 and pts[1][0] < pts[0][0]: + # Top side but first segment is horizontal going left — snap X + pts[1] = [pts[0][0], pts[1][1]] + + # Determine dst_side from end point position on node + if dst_node and len(pts) >= 3: + ex, ey = pts[-1] + nx, ny, nw, nh = dst_node["x"], dst_node["y"], dst_node.get("width", 60), dst_node.get("height", 60) + if abs(ex - nx) < 5: + dst_side = "left" + elif abs(ex - (nx + nw)) < 5: + dst_side = "right" + elif abs(ey - ny) < 5: + dst_side = "top" + else: + dst_side = "bottom" + + if dst_side == "left" and pts[-2][0] < pts[-1][0]: + pts[-2] = [pts[-1][0], pts[-2][1]] + elif dst_side == "right" and pts[-2][0] > pts[-1][0]: + pts[-2] = [pts[-1][0], pts[-2][1]] + elif dst_side == "top" and pts[-2][1] < pts[-1][1]: + pts[-2] = [pts[-2][0], pts[-1][1]] + elif dst_side == "bottom" and pts[-2][1] > pts[-1][1]: + pts[-2] = [pts[-2][0], pts[-1][1]] # Final pass: eliminate diagonal segments by inserting intermediate points for e in edges: From a1e2614227e8198e99fc211ccf14f0a72e363506 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sun, 28 Jun 2026 01:38:37 +0900 Subject: [PATCH 31/79] fix(layout): disable _spread_overlapping_bends to guarantee perpendicularity The spread/crossing-resolution post-processing was introducing diagonal segments and backwards arrows that subsequent fixes could not reliably repair. Disabling it guarantees all arrows exit/enter icon edges perpendicularly with zero diagonal segments. Trade-off: some parallel bends may overlap visually. This will be re-addressed with a more constrained spread algorithm that maintains axis-alignment invariants. --- skill/sdpm/layout/__init__.py | 82 +++-------------------------------- 1 file changed, 5 insertions(+), 77 deletions(-) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 04c442fd..5868f9cf 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -796,83 +796,12 @@ def _layout_route_connections(connections, nodes, groups=None): # T8: Align bend positions for fan-out/fan-in only when "fan": "merge" is set _align_fan_bends(edges, conn_sides, connections) - # Save fan-out edge points (they should not be modified by spread/crossing logic) - import copy - fanout_saved = {i: copy.deepcopy(e["points"]) for i, e in enumerate(edges) if e.get("_fanout")} - - # T9: Spread overlapping elbow bends from the same source - _spread_overlapping_bends(edges, conn_sides, connections) - - # Restore fan-out edge points - for i, pts in fanout_saved.items(): - edges[i]["points"] = pts + # T9: Spread overlapping bends — currently disabled as it introduces + # perpendicularity violations that are difficult to repair. + # TODO: re-enable with proper constraint that never produces diagonals or backwards. + # _spread_overlapping_bends(edges, conn_sides, connections) - # Post-process: fix bends that ended up inside node icons after spread - _fix_bends_inside_nodes(edges, nodes, connections) - - # Final pass: fix arrows going backwards from their start/end ports. - # Detect side from the port position relative to the source/destination node. - for ei, e in enumerate(edges): - pts = e["points"] - if len(pts) < 3: - continue - src_node = _find_node(nodes, e["from"]) - dst_node = _find_node(nodes, e["to"]) - - # Determine src_side from start point position on node edge - if src_node: - sx, sy = pts[0] - nx, ny, nw, nh = src_node["x"], src_node["y"], src_node.get("width", 60), src_node.get("height", 60) - # Check which edge the start point is closest to - dist_right = abs(sx - (nx + nw)) - dist_left = abs(sx - nx) - dist_bottom = abs(sy - (ny + nh)) - dist_top = abs(sy - ny) - min_dist = min(dist_right, dist_left, dist_bottom, dist_top) - if min_dist == dist_right: - src_side = "right" - elif min_dist == dist_left: - src_side = "left" - elif min_dist == dist_bottom: - src_side = "bottom" - else: - src_side = "top" - - if src_side == "right" and pts[1][0] < pts[0][0]: - pts[1] = [pts[0][0], pts[1][1]] - elif src_side == "left" and pts[1][0] > pts[0][0]: - pts[1] = [pts[0][0], pts[1][1]] - elif src_side == "bottom" and pts[1][1] < pts[0][1]: - pts[1] = [pts[1][0], pts[0][1]] - elif src_side == "top" and pts[1][1] > pts[0][1]: - pts[1] = [pts[1][0], pts[0][1]] - elif src_side == "top" and abs(pts[1][1] - pts[0][1]) < 3 and pts[1][0] < pts[0][0]: - # Top side but first segment is horizontal going left — snap X - pts[1] = [pts[0][0], pts[1][1]] - - # Determine dst_side from end point position on node - if dst_node and len(pts) >= 3: - ex, ey = pts[-1] - nx, ny, nw, nh = dst_node["x"], dst_node["y"], dst_node.get("width", 60), dst_node.get("height", 60) - if abs(ex - nx) < 5: - dst_side = "left" - elif abs(ex - (nx + nw)) < 5: - dst_side = "right" - elif abs(ey - ny) < 5: - dst_side = "top" - else: - dst_side = "bottom" - - if dst_side == "left" and pts[-2][0] < pts[-1][0]: - pts[-2] = [pts[-1][0], pts[-2][1]] - elif dst_side == "right" and pts[-2][0] > pts[-1][0]: - pts[-2] = [pts[-1][0], pts[-2][1]] - elif dst_side == "top" and pts[-2][1] < pts[-1][1]: - pts[-2] = [pts[-2][0], pts[-1][1]] - elif dst_side == "bottom" and pts[-2][1] > pts[-1][1]: - pts[-2] = [pts[-2][0], pts[-1][1]] - - # Final pass: eliminate diagonal segments by inserting intermediate points + # Safety net: if any diagonal segments remain, insert L-shaped intermediates for e in edges: new_pts = [e["points"][0]] pts = e["points"] @@ -880,7 +809,6 @@ def _layout_route_connections(connections, nodes, groups=None): dx = abs(pts[k][0] - new_pts[-1][0]) dy = abs(pts[k][1] - new_pts[-1][1]) if dx > 3 and dy > 3: - # Insert an L-shaped intermediate: go horizontal first, then vertical new_pts.append([pts[k][0], new_pts[-1][1]]) new_pts.append(pts[k]) e["points"] = new_pts From 9019b5ead606acd7420cef1f160b3f515be5e62b Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sun, 28 Jun 2026 01:43:42 +0900 Subject: [PATCH 32/79] feat(layout): add safe bend separation that preserves axis-alignment New _safe_separate_bends function spreads overlapping vertical bends apart while maintaining strict invariants: - Never moves start/end points (port-anchored) - Only shifts X of vertical segments - Maintains axis-alignment (no diagonal segments introduced) - Minimum 30px separation between parallel vertical bends - Skips fan-out edges (they have intentionally shared bend X) Diagonal=0, Backwards=0, Crossings=21 (crossings to be addressed by node order optimization next). --- skill/sdpm/layout/__init__.py | 85 +++++++++++++++++++++++++++++++++-- 1 file changed, 81 insertions(+), 4 deletions(-) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 5868f9cf..98ef0163 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -796,10 +796,87 @@ def _layout_route_connections(connections, nodes, groups=None): # T8: Align bend positions for fan-out/fan-in only when "fan": "merge" is set _align_fan_bends(edges, conn_sides, connections) - # T9: Spread overlapping bends — currently disabled as it introduces - # perpendicularity violations that are difficult to repair. - # TODO: re-enable with proper constraint that never produces diagonals or backwards. - # _spread_overlapping_bends(edges, conn_sides, connections) + # T9: Safe bend separation — shift overlapping vertical bends apart + # while preserving axis-alignment (only move X of vertical segments, + # never touch start/end points). + _safe_separate_bends(edges) + + return edges + + +def _safe_separate_bends(edges): + """Separate overlapping vertical bends by shifting their X position. + + Rules: + - Only shifts X of vertical segments (never Y of horizontal segments) + - Never moves pts[0] or pts[-1] (port-anchored) + - When shifting a vertical segment's X, also updates the adjacent horizontal + segments' endpoints to maintain connectivity + - Minimum separation: 30px between parallel vertical bends + """ + MIN_SEP = 30 + + # Collect vertical segments: (edge_idx, seg_start_idx, x, y_lo, y_hi) + v_segs = [] + for ei, e in enumerate(edges): + pts = e["points"] + if e.get("_fanout"): + continue + for k in range(len(pts) - 1): + if abs(pts[k][0] - pts[k+1][0]) <= 3 and abs(pts[k][1] - pts[k+1][1]) > 10: + # Vertical segment, not touching start/end + if k == 0 or k == len(pts) - 2: + continue + x = pts[k][0] + y_lo = min(pts[k][1], pts[k+1][1]) + y_hi = max(pts[k][1], pts[k+1][1]) + v_segs.append((ei, k, x, y_lo, y_hi)) + + # Group vertical segments by similar X (within MIN_SEP) + v_segs.sort(key=lambda s: s[2]) + groups = [] + current_group = [] + for seg in v_segs: + if current_group and abs(seg[2] - current_group[0][2]) > MIN_SEP: + if len(current_group) >= 2: + groups.append(current_group) + current_group = [seg] + else: + current_group.append(seg) + if len(current_group) >= 2: + groups.append(current_group) + + # For each group, check if Y ranges overlap and spread X positions + for group in groups: + # Filter to segments with overlapping Y ranges + overlapping = [] + for i, seg in enumerate(group): + for other in group[i+1:]: + if seg[3] < other[4] and seg[4] > other[3]: + if seg not in overlapping: + overlapping.append(seg) + if other not in overlapping: + overlapping.append(other) + + if len(overlapping) < 2: + continue + + # Spread evenly around the center X + center_x = sum(s[2] for s in overlapping) / len(overlapping) + n = len(overlapping) + for i, (ei, k, old_x, y_lo, y_hi) in enumerate(sorted(overlapping, key=lambda s: s[3])): + new_x = round(center_x + (i - (n-1)/2) * MIN_SEP) + if new_x == old_x: + continue + pts = edges[ei]["points"] + # Shift the vertical segment + pts[k] = [new_x, pts[k][1]] + pts[k+1] = [new_x, pts[k+1][1]] + # Fix adjacent horizontal segments + if k > 0 and abs(pts[k-1][1] - pts[k][1]) <= 3: + pts[k-1] = [pts[k-1][0], pts[k-1][1]] # keep, connectivity maintained by polyline + if k+2 < len(pts) and abs(pts[k+1][1] - pts[k+2][1]) <= 3: + pass # horizontal after — connectivity OK since pts[k+1] x changed # Safety net: if any diagonal segments remain, insert L-shaped intermediates for e in edges: From f1766284ff4fc9bc8611a3aaaceb63b372f48e6d Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sun, 28 Jun 2026 01:52:57 +0900 Subject: [PATCH 33/79] feat(layout): enhance optimize_order for mixed groups and cross-group edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite _optimize_group_order to handle mixed groups (children that are a mix of leaves and sub-groups). Key improvements: - _collect_leaf_ids: recursively gather all leaf IDs from any subtree - _find_min_crossing_order_mixed: brute-force permutation search that works with mixed children using a two-layer position model - _compute_global_peer_positions: positions external peers by DFS order - _count_crossings_for_mixed_order: categorizes edges as internal→external, external→internal, or internal→internal to avoid false crossing counts - _heuristic_sort_key_mixed: fallback for >7-child groups Result: 21→19 crossings on the multi-agent test case. Maintains Diagonal=0, Backwards=0 guarantees. --- skill/sdpm/layout/__init__.py | 243 +++++++++++++++++++++++++++++++--- 1 file changed, 221 insertions(+), 22 deletions(-) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 98ef0163..b3a3e8c9 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -4,11 +4,12 @@ def optimize_order(tree): - """Pre-process: reorder children in leaf-only groups to minimize edge crossings. + """Pre-process: reorder children in groups to minimize edge crossings. - Uses brute-force permutation search for small groups (≤7 leaves) and - heuristic sorting for larger ones. Counts actual crossing pairs to find - the optimal order. + Handles both leaf-only AND mixed groups (groups containing sub-groups). + Uses brute-force permutation search for small groups (≤7 children) and + heuristic sorting for larger ones. Counts actual crossing pairs using a + two-layer position model (internal positions + external peer positions). Mutates tree in-place. Call before _layout_scale. """ @@ -18,53 +19,251 @@ def optimize_order(tree): _optimize_group_order(tree, connections) -def _optimize_group_order(node, connections): - """Recursively optimize child order within leaf-only groups.""" +def _optimize_group_order(node, connections, root=None): + """Recursively optimize child order within groups (leaf-only AND mixed).""" + if root is None: + root = node children = node.get("children", []) if not children: return + # Recurse depth-first so inner groups are optimized before outer ones for child in children: - _optimize_group_order(child, connections) + _optimize_group_order(child, connections, root) - leaf_children = [c for c in children if not c.get("children")] - if len(leaf_children) < 2 or len(leaf_children) != len(children): + if len(children) < 2: return - leaf_ids = [c["id"] for c in children] + # Collect ALL leaf ids reachable from each child (for mixed groups) + child_leaf_ids = {} + for c in children: + ids = [] + _collect_leaf_ids(c, ids) + child_leaf_ids[c["id"]] = ids - # Collect connections relevant to this group's leaves + # All leaf ids in this group (union of children's leaves) + all_leaf_ids = set() + for ids in child_leaf_ids.values(): + all_leaf_ids.update(ids) + + if not all_leaf_ids: + return + + # Collect connections relevant to any leaf in this group relevant = [] for conn in connections: src, dst = conn["from"], conn["to"] - src_in = src in leaf_ids - dst_in = dst in leaf_ids - if src_in or dst_in: + if src in all_leaf_ids or dst in all_leaf_ids: relevant.append(conn) if not relevant: return - # Identify internal connections (both src and dst within this group) - # Their src must appear before dst in any valid permutation. + # Identify internal order constraints: + # If a connection goes from a leaf in child A to a leaf in child B, + # child A must come before child B. + leaf_to_child_id = {} + for cid, leaf_ids in child_leaf_ids.items(): + for lid in leaf_ids: + leaf_to_child_id[lid] = cid + internal_order_constraints = [] for conn in connections: src, dst = conn["from"], conn["to"] - if src in leaf_ids and dst in leaf_ids: - internal_order_constraints.append((src, dst)) + if src in leaf_to_child_id and dst in leaf_to_child_id: + src_child = leaf_to_child_id[src] + dst_child = leaf_to_child_id[dst] + if src_child != dst_child: + internal_order_constraints.append((src_child, dst_child)) - # For brute-force: try all permutations if ≤7 leaves + # For brute-force: try all permutations if ≤7 children if len(children) <= 7: - best_order = _find_min_crossing_order(children, relevant, connections, internal_order_constraints) + best_order = _find_min_crossing_order_mixed( + children, relevant, connections, internal_order_constraints, + child_leaf_ids, root + ) if best_order is not None: node["children"] = best_order return # Fallback heuristic for larger groups: sort by connected peer position flat_order = [] - _flatten_ids_from_root(node, flat_order) + _flatten_ids_from_root(root, flat_order) id_position = {nid: i for i, nid in enumerate(flat_order)} - node["children"] = sorted(children, key=lambda c: _heuristic_sort_key(c["id"], connections, id_position)) + node["children"] = sorted(children, key=lambda c: _heuristic_sort_key_mixed(c, connections, id_position, child_leaf_ids)) + + +def _collect_leaf_ids(node, out): + """Collect all leaf node ids reachable from a node.""" + children = node.get("children", []) + if not children: + if "id" in node: + out.append(node["id"]) + return + for child in children: + _collect_leaf_ids(child, out) + + +def _find_min_crossing_order_mixed(children, relevant, all_connections, internal_order_constraints, child_leaf_ids, root): + """Try all permutations of children (mixed groups) and return the one with fewest crossings. + + For mixed groups, each child may be a leaf OR a sub-group containing multiple leaves. + The crossing count uses ALL leaves within each child's subtree. + """ + from itertools import permutations + + best_crossings = None + best_perm = None + + # Build a global position map from the full tree (excluding this group's leaves) + global_positions = _compute_global_peer_positions(children, all_connections, child_leaf_ids, root) + + for perm in permutations(children): + perm_ids = [c["id"] for c in perm] + + # Skip permutations that violate internal order constraints + if internal_order_constraints: + valid = True + for src, dst in internal_order_constraints: + if src in perm_ids and dst in perm_ids: + if perm_ids.index(src) > perm_ids.index(dst): + valid = False + break + if not valid: + continue + + crossings = _count_crossings_for_mixed_order(perm, relevant, global_positions, child_leaf_ids) + if best_crossings is None or crossings < best_crossings: + best_crossings = crossings + best_perm = list(perm) + if crossings == 0: + break + + return best_perm + + +def _compute_global_peer_positions(children, all_connections, child_leaf_ids, root): + """Compute normalized positions for external peers. + + External peers are nodes NOT contained in any of the children being permuted. + Their position is based on DFS order of leaf nodes in the root tree, + normalized to a [0, N] range where N is the number of internal leaf slots. + """ + # All leaves within this group + all_internal = set() + for ids in child_leaf_ids.values(): + all_internal.update(ids) + + # Get global DFS order of ALL leaf nodes only + global_leaves = [] + _collect_leaf_ids(root, global_leaves) + + # Only external leaves get positions + external_leaves = [lid for lid in global_leaves if lid not in all_internal] + if not external_leaves: + return {} + + # Assign sequential positions to external leaves + return {lid: i for i, lid in enumerate(external_leaves)} + + +def _count_crossings_for_mixed_order(perm, relevant, global_positions, child_leaf_ids): + """Count edge crossings for a specific permutation of children in a mixed group. + + Two edges cross if their internal endpoints are in one order but their + external endpoints are in the opposite order. For edges where BOTH endpoints + are internal, they cross if one child's position inverts relative to another. + + Uses a two-layer approach: + - Internal positions: assigned based on permutation order (sequential ints) + - External positions: from global_positions (sequential ints, different namespace) + + For crossing detection, only edges sharing the same "side" (both internal-to-external + or both internal-to-internal) can cross each other. + """ + # Assign positions to all internal leaves based on the permutation order + internal_positions = {} + pos_counter = 0 + for child in perm: + cid = child["id"] + leaves = child_leaf_ids.get(cid, []) + if not leaves: + internal_positions[cid] = pos_counter + pos_counter += 1 + else: + for lid in leaves: + internal_positions[lid] = pos_counter + pos_counter += 1 + + # Categorize edges: + # Type A: internal→external (src is internal, dst is external) + # Type B: external→internal (src is external, dst is internal) + # Type C: internal→internal (both endpoints internal) + edges_a = [] # (internal_pos, external_pos) + edges_b = [] # (external_pos, internal_pos) + edges_c = [] # (internal_pos_src, internal_pos_dst) + + for conn in relevant: + src, dst = conn["from"], conn["to"] + src_int = internal_positions.get(src) + dst_int = internal_positions.get(dst) + src_ext = global_positions.get(src) + dst_ext = global_positions.get(dst) + + if src_int is not None and dst_int is not None: + edges_c.append((src_int, dst_int)) + elif src_int is not None and dst_ext is not None: + edges_a.append((src_int, dst_ext)) + elif src_ext is not None and dst_int is not None: + edges_b.append((src_ext, dst_int)) + + # Count crossings within each category + crossings = 0 + + # Type A crossings: two edges from internal to external cross if + # internal order and external order are inverted + for i in range(len(edges_a)): + for j in range(i + 1, len(edges_a)): + a1, b1 = edges_a[i] + a2, b2 = edges_a[j] + if (a1 - a2) * (b1 - b2) < 0: + crossings += 1 + + # Type B crossings: two edges from external to internal cross if + # external order and internal order are inverted + for i in range(len(edges_b)): + for j in range(i + 1, len(edges_b)): + a1, b1 = edges_b[i] + a2, b2 = edges_b[j] + if (a1 - a2) * (b1 - b2) < 0: + crossings += 1 + + # Type C crossings: internal-to-internal edges + for i in range(len(edges_c)): + for j in range(i + 1, len(edges_c)): + a1, b1 = edges_c[i] + a2, b2 = edges_c[j] + if (a1 - a2) * (b1 - b2) < 0: + crossings += 1 + + return crossings + + +def _heuristic_sort_key_mixed(child, connections, id_position, child_leaf_ids): + """Heuristic sort key for a child (leaf or sub-group) in a mixed group.""" + cid = child["id"] + leaf_ids = child_leaf_ids.get(cid, [cid]) + + weights = [] + for lid in leaf_ids: + for conn in connections: + if conn["from"] == lid and conn["to"] in id_position: + weights.append(id_position[conn["to"]]) + if conn["to"] == lid and conn["from"] in id_position: + weights.append(id_position[conn["from"]]) + if weights: + return sum(weights) / len(weights) + return id_position.get(cid, 0) def _find_min_crossing_order(children, relevant, all_connections, internal_order_constraints=None): From cabcd9c02b4370112bc889c8558876c3f4971c2e Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sun, 28 Jun 2026 12:10:32 +0900 Subject: [PATCH 34/79] feat(layout): pierce-aware bend optimization + side/detour routing Add a multi-stage routing optimizer that runs after elbow generation: - _optimize_bends: coordinate-descent that slides the free middle bend of 4-point VHV/HVH elbows to minimize crossings + weighted icon pierces. Endpoints never move (perpendicularity preserved); no diagonals. - _reselect_sides: re-route still-piercing edges through alternative (src_side, dst_side) pairs and ports, gated lexicographically on (crossings, pierces) with crossings as a hard ceiling. - _detour_around_pierces: splice axis-aligned jogs around obstacles that no side choice can clear, same global lexicographic gate. - Fix fan-out side-forcing: only force a shared exit side when a strict majority of targets agree, and never force a side the target lies opposite to (was causing backwards arrows through the source icon). Add skill/scripts/layout_qa.py: objective QA harness measuring crossings, pierces, diagonals, port perpendicularity, and backwards segments. Measured (crossings/pierces, 1720x800): ideal_v3: 11/3 -> 0/0 ideal_v4: -/- -> 5/0 ideal_v6: 16/1 -> 2/0 multi_agent: 25/9 -> 8/4 (residual is structural: bad grouping) agent_tree: 0/0 -> 0/0 (no regression) All invariants clean: 0 diagonals, 0 bad ports, 0 backwards. --- skill/scripts/layout_qa.py | 230 +++++++++++++++++ skill/sdpm/layout/__init__.py | 457 +++++++++++++++++++++++++++++++++- 2 files changed, 679 insertions(+), 8 deletions(-) create mode 100644 skill/scripts/layout_qa.py diff --git a/skill/scripts/layout_qa.py b/skill/scripts/layout_qa.py new file mode 100644 index 00000000..1126259f --- /dev/null +++ b/skill/scripts/layout_qa.py @@ -0,0 +1,230 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +"""Layout QA harness: objective quality metrics for the layout engine. + +Runs the REAL engine pipeline (same as pptx_builder.cmd_layout) on a logical +structure JSON, then measures geometric quality: + - crossings : pairs of edge segments that intersect + - pierces : edge segments passing through a non-endpoint node icon + - diagonals : segments that are neither horizontal nor vertical + - bad_ports : first/last segment not perpendicular to its node edge + - backwards : first segment travels opposite to the port's outward normal + +Usage: + python3 layout_qa.py [--width W] [--height H] [--json] +""" + +import argparse +import copy +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from sdpm.layout import ( # noqa: E402 + optimize_order, + _layout_scale, + _layout_translate, + _layout_collect, + _layout_route_connections, + _count_all_crossings, + _find_node, +) + +_TOL = 2 + + +def _build_layout(tree, target_w=None, target_h=None): + """Replicate cmd_layout's scale/translate/collect/route pipeline.""" + direction = tree.get("direction", "horizontal") + align = tree.get("align", "center") + optimize_order(tree) + + def build_root(): + return { + "id": "_root", + "children": copy.deepcopy(tree.get("children", tree.get("nodes", []))), + "direction": direction, + "align": align, + } + + root = build_root() + _layout_scale(root, direction, align) + cum_h = cum_v = 1.0 + if target_w or target_h: + for _ in range(10): + rb = root["_bindings"] + sx = target_w / rb[2] if target_w else 1.0 + sy = target_h / rb[3] if target_h else 1.0 + if abs(sx - 1.0) < 0.03 and abs(sy - 1.0) < 0.03: + break + cum_h *= sx + cum_v *= sy + root = build_root() + _layout_scale(root, direction, align, cum_h, cum_v) + + rb = root["_bindings"] + _layout_translate(root, -rb[0], -rb[1]) + nodes, groups = {}, {} + for child in root["children"]: + _layout_collect(child, nodes, groups) + edges = _layout_route_connections(tree.get("connections", []), nodes, groups) + return nodes, groups, edges, root["_bindings"] + + +def _seg_pierces_rect(p1, p2, n, inset=4): + rx, ry = n["x"], n["y"] + rw, rh = n.get("width", 60), n.get("height", n.get("width", 60)) + x0, y0, x1, y1 = rx + inset, ry + inset, rx + rw - inset, ry + rh - inset + ax, ay = p1 + bx, by = p2 + if ax == bx: # vertical + return x0 < ax < x1 and min(ay, by) < y1 and max(ay, by) > y0 + if ay == by: # horizontal + return y0 < ay < y1 and min(ax, bx) < x1 and max(ax, bx) > x0 + # diagonal: bounding-box overlap as a coarse test + return min(ax, bx) < x1 and max(ax, bx) > x0 and min(ay, by) < y1 and max(ay, by) > y0 + + +def _side_of(node, pt): + """Classify which edge a port point sits on. + + Ports on the bottom edge are offset downward by the label height, so a + point below the icon that is horizontally inside the icon's x-span is a + bottom port (not a left/right one). Likewise points above are top ports. + """ + x, y = pt + cx, cy = node["x"], node["y"] + w = node.get("width", 60) + h = node.get("height", w) + # Inside the icon's horizontal span and at/below or at/above → top/bottom port + if cx - _TOL <= x <= cx + w + _TOL: + if y >= cy + h - _TOL: + return "bottom" + if y <= cy + _TOL: + return "top" + # Inside the icon's vertical span → left/right port + if cy - _TOL <= y <= cy + h + _TOL: + if x <= cx + _TOL: + return "left" + if x >= cx + w - _TOL: + return "right" + # Fallback: nearest edge + d = { + "left": abs(x - cx), + "right": abs(x - (cx + w)), + "top": abs(y - cy), + "bottom": abs(y - (cy + h)), + } + return min(d, key=d.get) + + +def measure(tree, target_w=1720, target_h=800): + nodes, groups, edges, rb = _build_layout(tree, target_w, target_h) + + crossings = _count_all_crossings(edges) + + pierces = [] + diagonals = [] + bad_ports = [] + backwards = [] + + for e in edges: + pts = e["points"] + if len(pts) < 2: + continue + ig = {e["from"], e["to"]} + src = _find_node(nodes, e["from"]) + dst = _find_node(nodes, e["to"]) + + # pierces + for nid, n in nodes.items(): + short = nid.rsplit(".", 1)[-1] + if short in ig or nid in ig: + continue + for k in range(len(pts) - 1): + if _seg_pierces_rect(pts[k], pts[k + 1], n): + pierces.append((f"{e['from'].rsplit('.',1)[-1]}->{e['to'].rsplit('.',1)[-1]}", short)) + break + + # diagonals + for k in range(len(pts) - 1): + dx = abs(pts[k][0] - pts[k + 1][0]) + dy = abs(pts[k][1] - pts[k + 1][1]) + if dx > _TOL and dy > _TOL: + diagonals.append((f"{e['from'].rsplit('.',1)[-1]}->{e['to'].rsplit('.',1)[-1]}", pts[k], pts[k + 1])) + + # port perpendicularity + backwards (skip detours which legitimately exit bottom) + if src is not None: + ss = _side_of(src, pts[0]) + seg = (pts[0], pts[1]) + horiz = abs(seg[0][1] - seg[1][1]) <= _TOL + vert = abs(seg[0][0] - seg[1][0]) <= _TOL + exp_horiz = ss in ("left", "right") + if exp_horiz and not horiz: + bad_ports.append((f"src {e['from'].rsplit('.',1)[-1]}", ss, "not-horizontal")) + if (not exp_horiz) and not vert: + bad_ports.append((f"src {e['from'].rsplit('.',1)[-1]}", ss, "not-vertical")) + # backwards: exits right but first seg goes left, etc. + if ss == "right" and seg[1][0] < seg[0][0] - _TOL: + backwards.append(f"{e['from'].rsplit('.',1)[-1]}[right]->{e['to'].rsplit('.',1)[-1]}") + if ss == "left" and seg[1][0] > seg[0][0] + _TOL: + backwards.append(f"{e['from'].rsplit('.',1)[-1]}[left]->{e['to'].rsplit('.',1)[-1]}") + if ss == "bottom" and seg[1][1] < seg[0][1] - _TOL: + backwards.append(f"{e['from'].rsplit('.',1)[-1]}[bottom]->{e['to'].rsplit('.',1)[-1]}") + if ss == "top" and seg[1][1] > seg[0][1] + _TOL: + backwards.append(f"{e['from'].rsplit('.',1)[-1]}[top]->{e['to'].rsplit('.',1)[-1]}") + + if dst is not None: + ds = _side_of(dst, pts[-1]) + seg = (pts[-2], pts[-1]) + horiz = abs(seg[0][1] - seg[1][1]) <= _TOL + vert = abs(seg[0][0] - seg[1][0]) <= _TOL + exp_horiz = ds in ("left", "right") + if exp_horiz and not horiz: + bad_ports.append((f"dst {e['to'].rsplit('.',1)[-1]}", ds, "not-horizontal")) + if (not exp_horiz) and not vert: + bad_ports.append((f"dst {e['to'].rsplit('.',1)[-1]}", ds, "not-vertical")) + + return { + "crossings": crossings, + "pierces": len(pierces), + "diagonals": len(diagonals), + "bad_ports": len(bad_ports), + "backwards": len(backwards), + "size": [round(rb[2]), round(rb[3])], + "detail": { + "pierces": pierces, + "diagonals": diagonals, + "bad_ports": bad_ports, + "backwards": backwards, + }, + } + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("input") + ap.add_argument("--width", type=int, default=1720) + ap.add_argument("--height", type=int, default=800) + ap.add_argument("--json", action="store_true", help="emit JSON only") + args = ap.parse_args() + + tree = json.loads(Path(args.input).read_text(encoding="utf-8")) + result = measure(tree, args.width, args.height) + + if args.json: + print(json.dumps(result, ensure_ascii=False)) + return + + print(f"crossings={result['crossings']} pierces={result['pierces']} " + f"diagonals={result['diagonals']} bad_ports={result['bad_ports']} " + f"backwards={result['backwards']} size={result['size']}") + for cat in ("pierces", "diagonals", "bad_ports", "backwards"): + for d in result["detail"][cat]: + print(f" {cat}: {d}") + + +if __name__ == "__main__": + main() diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index b3a3e8c9..9657d5c3 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -799,15 +799,22 @@ def _layout_route_connections(connections, nodes, groups=None): _src_side_votes[sid][s_side] = _src_side_votes[sid].get(s_side, 0) + 1 fanout_sources = {sid for sid, cnt in _src_target_count.items() if cnt >= 2} - # Pre-decide side for fan-out sources: prefer "right" > "left" > majority + # Pre-decide a shared exit side for a fan-out source ONLY when a strict + # majority of its targets naturally want the same side. Forcing one side + # when targets are scattered (e.g. one to the right, one below-left) makes + # the minority arrows exit backwards through the source icon. When there is + # no majority, leave the source un-decided so each edge keeps its natural + # side. Among ties, prefer horizontal ("right" > "left") for left→right flow. for sid in fanout_sources: votes = _src_side_votes.get(sid, {}) - if "right" in votes: - decided_src_side[sid] = "right" - elif "left" in votes: - decided_src_side[sid] = "left" - else: - decided_src_side[sid] = max(votes, key=votes.get) if votes else "right" + if not votes: + continue + total = sum(votes.values()) + # candidate = the side with the most votes (horizontal preferred on tie) + ordered = sorted(votes.items(), key=lambda kv: (-kv[1], {"right": 0, "left": 1, "bottom": 2, "top": 3}.get(kv[0], 9))) + best_side, best_n = ordered[0] + if best_n > total / 2: + decided_src_side[sid] = best_side conn_sides = [] for i, conn in enumerate(connections): @@ -853,7 +860,27 @@ def _layout_route_connections(connections, nodes, groups=None): v_sides = {"top", "bottom"} natural_axis = "h" if src_side in h_sides else "v" decided_axis = "h" if decided in h_sides else "v" - apply_decided = (src_id in fanout_sources) or (natural_axis == decided_axis) + # Never apply the decided side if the target lies on the OPPOSITE + # side, which would make the arrow exit backwards through the + # source icon (e.g. forcing "right" when the target is to the + # left). Check the target's actual direction relative to source. + decided_is_backwards = False + if src and dst: + s_cx = src["x"] + src.get("width", 60) / 2 + s_cy = src["y"] + src.get("height", 60) / 2 + d_cx = dst["x"] + dst.get("width", 60) / 2 + d_cy = dst["y"] + dst.get("height", 60) / 2 + if decided == "right" and d_cx < s_cx: + decided_is_backwards = True + elif decided == "left" and d_cx > s_cx: + decided_is_backwards = True + elif decided == "bottom" and d_cy < s_cy: + decided_is_backwards = True + elif decided == "top" and d_cy > s_cy: + decided_is_backwards = True + apply_decided = (not decided_is_backwards) and ( + (src_id in fanout_sources) or (natural_axis == decided_axis) + ) if apply_decided: src_side = decided # Fix dst_side for fan-out: use opposite side, but if dst @@ -1000,6 +1027,34 @@ def _layout_route_connections(connections, nodes, groups=None): # never touch start/end points). _safe_separate_bends(edges) + # T10: Bend optimization — slide each free (middle) bend of an elbow path + # along its axis to minimize a global cost (crossings + weighted icon + # pierces). The free bend of a 4-point VHV/HVH path can move without + # touching the port-anchored endpoints, so axis-alignment and + # perpendicularity are preserved. This is the primary crossing/pierce + # reducer; it never introduces diagonals or backwards segments. + _optimize_bends(edges, nodes) + + # T11: Side/port reselection — a remaining pierce is usually a poor choice + # of which icon edge the arrow attaches to. Re-route each still-piercing + # edge through the elbow router with an alternative (src_side, dst_side), + # keeping it only if it lowers pierces without raising crossings. Adds no + # segments (unlike a detour), so it cannot cause a crossing blow-up, and + # endpoints stay perpendicular because every port comes from _port_point. + obstacles_re = [o for o in obstacles if o.get("_node")] + _reselect_sides(edges, nodes, obstacles_re) + _optimize_bends(edges, nodes) + + # T12: Obstacle detour — for pierces that no side/port choice can clear + # (e.g. an icon stacked directly between source and target in the same + # column), splice an axis-aligned jog around the obstacle. Each candidate + # is judged against the full live edge set with a lexicographic + # (crossings, pierces) gate: a jog is committed only if it does not raise + # the global crossing count and strictly lowers pierces. The jog is + # spliced into the interior of a segment, so endpoints never move and no + # diagonal is ever produced. + _detour_around_pierces(edges, nodes) + return edges @@ -1342,6 +1397,392 @@ def _count_all_crossings(edges): return count +_PIERCE_INSET = 4 +_PIERCE_WEIGHT = 4 + + +def _seg_pierces_node(p1, p2, n): + """True if axis-aligned segment p1-p2 passes through node n's interior.""" + rx, ry = n["x"], n["y"] + rw, rh = n.get("width", 60), n.get("height", n.get("width", 60)) + x0, y0 = rx + _PIERCE_INSET, ry + _PIERCE_INSET + x1, y1 = rx + rw - _PIERCE_INSET, ry + rh - _PIERCE_INSET + ax, ay = p1 + bx, by = p2 + if ax == bx: # vertical + return x0 < ax < x1 and min(ay, by) < y1 and max(ay, by) > y0 + if ay == by: # horizontal + return y0 < ay < y1 and min(ax, bx) < x1 and max(ax, bx) > x0 + return False + + +def _count_node_pierces(edges, nodes): + """Count (edge, node) pairs where an edge passes through a non-endpoint icon.""" + count = 0 + for e in edges: + pts = e["points"] + if len(pts) < 2: + continue + ignore = {e["from"], e["to"]} + for nid, n in nodes.items(): + short = nid.rsplit(".", 1)[-1] + if nid in ignore or short in ignore: + continue + for k in range(len(pts) - 1): + if _seg_pierces_node(pts[k], pts[k + 1], n): + count += 1 + break + return count + + +def _edge_free_bend(pts): + """Return ('x'|'y', lo, hi) for the movable middle bend of a 4-point elbow. + + A VHV/HVH path's two middle points share one coordinate (the trunk + position) that can slide between the two endpoints without moving the + port-anchored endpoints or creating diagonals. Returns None for paths + that have no such free bend (straight lines, detours, fan-outs). + """ + if len(pts) != 4: + return None + # HVH: horiz, vert, horiz → middle two points share X (vertical trunk) + if pts[0][1] == pts[1][1] and pts[1][0] == pts[2][0] and pts[2][1] == pts[3][1]: + return ("x", pts[0][0], pts[3][0]) + # VHV: vert, horiz, vert → middle two points share Y (horizontal trunk) + if pts[0][0] == pts[1][0] and pts[1][1] == pts[2][1] and pts[2][0] == pts[3][0]: + return ("y", pts[0][1], pts[3][1]) + return None + + +_BEND_OPT_PASSES = 40 +_BEND_OPT_STEP = 4 +_BEND_OPT_INSET = 6 + + +def _optimize_bends(edges, nodes): + """Slide free elbow bends to minimize global crossings + weighted pierces. + + Coordinate descent: repeatedly try shifting each edge's free middle bend + to a range of candidate positions between its endpoints, keep the best. + Only the two middle points of a 4-point VHV/HVH path move, and only along + the trunk axis, so endpoints stay port-anchored and no diagonals appear. + Skips fan-out edges (they share a deliberate trunk) and detours. + """ + if not edges: + return edges + + def cost(es): + return _count_all_crossings(es) + _PIERCE_WEIGHT * _count_node_pierces(es, nodes) + + cur_cost = cost(edges) + for _ in range(_BEND_OPT_PASSES): + improved = False + for e in edges: + # Fan-out edges start on a shared trunk but are still free to be + # refined individually; optimizing them too reduces crossings + # without breaking axis-alignment (their middle bend is free). + fv = _edge_free_bend(e["points"]) + if not fv: + continue + axis, lo, hi = fv + lo, hi = min(lo, hi), max(lo, hi) + if hi - lo < 2 * _BEND_OPT_INSET: + continue + idx = 0 if axis == "x" else 1 + orig = e["points"][1][idx] + best_val = orig + best_cost = cur_cost + cand = int(lo) + _BEND_OPT_INSET + while cand < int(hi) - _BEND_OPT_INSET: + if cand != orig: + saved1, saved2 = e["points"][1][idx], e["points"][2][idx] + e["points"][1][idx] = cand + e["points"][2][idx] = cand + c = cost(edges) + if c < best_cost: + best_cost = c + best_val = cand + e["points"][1][idx] = saved1 + e["points"][2][idx] = saved2 + cand += _BEND_OPT_STEP + if best_val != orig: + e["points"][1][idx] = best_val + e["points"][2][idx] = best_val + cur_cost = best_cost + improved = True + if not improved: + break + return edges + + +_SIDE_RESELECT_PASSES = 6 +# (port_index, port_count): center, then 1/4, 1/2, 3/4 along the edge. +_PORT_TRIALS = [(0, 1), (0, 3), (1, 3), (2, 3)] + + +def _candidate_side_pairs(src, dst): + """Geometrically sane (src_side, dst_side) pairs; natural pair first. + + A "sane" side points toward the target — never away from it (which would + force a backwards U-turn). For a target down-and-right of the source this + yields src in {right, bottom} and dst in {left, top}. + """ + s_cx = src["x"] + src.get("width", 60) / 2 + s_cy = src["y"] + src.get("height", 60) / 2 + d_cx = dst["x"] + dst.get("width", 60) / 2 + d_cy = dst["y"] + dst.get("height", 60) / 2 + src_sides, dst_sides = [], [] + if d_cx >= s_cx: + src_sides.append("right") + dst_sides.append("left") + if d_cx <= s_cx: + src_sides.append("left") + dst_sides.append("right") + if d_cy >= s_cy: + src_sides.append("bottom") + dst_sides.append("top") + if d_cy <= s_cy: + src_sides.append("top") + dst_sides.append("bottom") + pairs = [] + nat = _auto_sides(src, dst, None) + pairs.append(nat) + for s in dict.fromkeys(src_sides): + for d in dict.fromkeys(dst_sides): + if (s, d) not in pairs: + pairs.append((s, d)) + return pairs + + +def _is_axis_aligned(pts): + return all( + pts[k][0] == pts[k + 1][0] or pts[k][1] == pts[k + 1][1] + for k in range(len(pts) - 1) + ) + + +def _entry_exit_ok(pts, src_side, dst_side): + """First segment perpendicular to src edge, last to dst edge (no backwards). + + Rejects degenerate zero-length leading/trailing segments, which would + otherwise read as both horizontal and vertical and let a parallel + (non-perpendicular) run slip through. + """ + if len(pts) < 2: + return False + if pts[0] == pts[1] or pts[-1] == pts[-2]: + return False + first_h = pts[0][1] == pts[1][1] + last_h = pts[-1][1] == pts[-2][1] + src_h = src_side in ("left", "right") + dst_h = dst_side in ("left", "right") + return (first_h == src_h) and (last_h == dst_h) + + +def _edge_pierces(e, nodes): + """True if edge e passes through any non-endpoint icon interior.""" + pts = e["points"] + if len(pts) < 2: + return False + ignore = {e["from"], e["to"]} + for nid, n in nodes.items(): + short = nid.rsplit(".", 1)[-1] + if nid in ignore or short in ignore: + continue + if any(_seg_pierces_node(pts[k], pts[k + 1], n) for k in range(len(pts) - 1)): + return True + return False + + +def _path_stability(pts): + """Tie-break key: prefer fewer, shorter segments.""" + length = sum( + abs(pts[k + 1][0] - pts[k][0]) + abs(pts[k + 1][1] - pts[k][1]) + for k in range(len(pts) - 1) + ) + return (len(pts), length) + + +def _reselect_sides(edges, nodes, obstacles): + """Remove pierces by re-choosing icon side/port, never by adding segments. + + For each still-piercing edge, re-route via the elbow router using + alternative (src_side, dst_side) pairs and port positions. Accept the + alternative only if it does not raise the global crossing count and + strictly lowers the global (crossings, pierces) tuple. Because crossings + is a hard ceiling, structural pierces (where every alternative raises + crossings) are correctly left untouched. Endpoints stay perpendicular + because they come from _port_point; no diagonals because _elbow_path only + emits H/V segments. + """ + for _ in range(_SIDE_RESELECT_PASSES): + piercing = [ + ei for ei, e in enumerate(edges) + if not e.get("_fanout") and len(e["points"]) >= 2 and _edge_pierces(e, nodes) + ] + piercing.sort(key=lambda ei: (edges[ei]["from"], edges[ei]["to"])) + committed = False + + for ei in piercing: + e = edges[ei] + src = _find_node(nodes, e["from"]) + dst = _find_node(nodes, e["to"]) + if not src or not dst: + continue + obs_excl = [o for o in obstacles if o.get("_node") not in (e["from"], e["to"])] + label_h = 30 if src.get("label") else 0 + + base = (_count_all_crossings(edges), _count_node_pierces(edges, nodes)) + orig_pts = e["points"] + best_pts = None + best_score = base + + for (s_side, d_side) in _candidate_side_pairs(src, dst): + for (si, sc) in _PORT_TRIALS: + for (qi, qc) in _PORT_TRIALS: + sp = _port_point(src, s_side, si, sc, label_h) + tp = _port_point(dst, d_side, qi, qc, label_h) + cand = _elbow_path(sp, tp, s_side, d_side, obs_excl) + if not _is_axis_aligned(cand): + continue + if not _entry_exit_ok(cand, s_side, d_side): + continue + e["points"] = cand + score = (_count_all_crossings(edges), _count_node_pierces(edges, nodes)) + e["points"] = orig_pts + # Hard ceiling on crossings; lexicographic improvement; + # tie-break on stability ONLY among equal-score candidates. + if score[0] > base[0]: + continue + better = ( + score < best_score + or (best_pts is not None and score == best_score + and _path_stability(cand) < _path_stability(best_pts)) + ) + if better: + best_score = score + best_pts = cand + + if best_pts is not None and best_score < base and best_score[0] <= base[0]: + e["points"] = best_pts + committed = True + + if not committed: + break + return edges + + +_DETOUR_FACE_MARGIN = 18 +_DETOUR_PASSES = 6 + + +def _jog_candidates(seg_a, seg_b, n): + """Axis-aligned bracket detours around node n for piercing segment a->b. + + Returns replacement point-lists that splice into the segment interior: + a -> (parallel run past one face of n) -> b. Every introduced segment is + horizontal or vertical, and seg_a/seg_b are preserved verbatim, so true + endpoints (when a/b are pts[0]/pts[-1]) never move. A candidate is + discarded when the obstacle extends past the segment's own span (the jog + would need to move an endpoint), guaranteeing the splice stays interior. + """ + nx0, ny0 = n["x"], n["y"] + nx1 = nx0 + n.get("width", 60) + ny1 = ny0 + n.get("height", n.get("width", 60)) + m = _DETOUR_FACE_MARGIN + out = [] + if seg_a[0] == seg_b[0]: # vertical segment at x=X -> jog left/right + x = seg_a[0] + y_lo, y_hi = min(seg_a[1], seg_b[1]), max(seg_a[1], seg_b[1]) + # Bracket arms run parallel just past the obstacle's vertical extent, + # clamped to stay strictly inside the segment span so the splice never + # moves an endpoint. If the obstacle protrudes past an end, clamp the + # arm to that endpoint (collapsing the stub to zero length there). + b_lo = max(y_lo, ny0 - m) + b_hi = min(y_hi, ny1 + m) + if b_lo >= b_hi: + return out # no overlap to bracket + for cx in (nx0 - m, nx1 + m): + out.append([list(seg_a), [x, b_lo], [cx, b_lo], [cx, b_hi], [x, b_hi], list(seg_b)]) + elif seg_a[1] == seg_b[1]: # horizontal segment at y=Y -> jog up/down + y = seg_a[1] + x_lo, x_hi = min(seg_a[0], seg_b[0]), max(seg_a[0], seg_b[0]) + b_lo = max(x_lo, nx0 - m) + b_hi = min(x_hi, nx1 + m) + if b_lo >= b_hi: + return out + for cy in (ny0 - m, ny1 + m): + out.append([list(seg_a), [b_lo, y], [b_lo, cy], [b_hi, cy], [b_hi, y], list(seg_b)]) + return out + + +def _detour_around_pierces(edges, nodes): + """Splice obstacle jogs to clear pierces no side/port choice can fix. + + Greedy, one commit at a time, re-measuring the full live edge set after + every tentative change. A jog is committed only if the global + (crossings, pierces) tuple strictly improves AND crossings does not rise. + This makes crossings monotone non-increasing — the separate-pass blow-up + (where locally-accepted jogs interacted to raise global crossings) cannot + recur. Structural pierces, whose every jog raises crossings, are left. + """ + if not edges: + return edges + + def cost(es): + return (_count_all_crossings(es), _count_node_pierces(es, nodes)) + + for _ in range(_DETOUR_PASSES): + cur = cost(edges) + if cur[1] == 0: + break + improved = False + + for e in edges: + # Fan-out edges are eligible: a jog around an obstacle does not + # break the shared-trunk concept, and the global gate below only + # commits it when it strictly helps. + pts = e["points"] + if len(pts) < 2: + continue + ignore = {e["from"], e["to"]} + base = cost(edges) + best_pts = None + best_score = base + # Scan each segment for a pierced obstacle; build jog candidates. + for k in range(len(pts) - 1): + seg_a, seg_b = pts[k], pts[k + 1] + for nid, n in nodes.items(): + short = nid.rsplit(".", 1)[-1] + if nid in ignore or short in ignore: + continue + if not _seg_pierces_node(seg_a, seg_b, n): + continue + for repl in _jog_candidates(seg_a, seg_b, n): + cand = pts[:k + 1] + repl[1:-1] + pts[k + 1:] + if not _is_axis_aligned(cand): + continue + saved = e["points"] + e["points"] = cand + score = cost(edges) + e["points"] = saved + if score[0] > base[0]: + continue + if score < best_score or ( + best_pts is not None and score == best_score + and _path_stability(cand) < _path_stability(best_pts) + ): + best_score = score + best_pts = cand + if best_pts is not None and best_score < base and best_score[0] <= base[0]: + e["points"] = best_pts + improved = True + + if not improved: + break + return edges + + _MIN_BEND_SEPARATION = 40 From cf33d55c9ca94d7cfa178c0c721483a8966cb691 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sun, 28 Jun 2026 12:33:30 +0900 Subject: [PATCH 35/79] feat(layout): graze-aware pierce detection + backwards penalty - Widen pierce detection to a keep-out margin around each icon (_PIERCE_INSET -6) so lines running flush against an edge are flagged and pushed away, matching what reads visually as touching the icon. - Remove the shared-trunk fan-out routing special case. A fixed trunk grazes any obstacle sitting between same-row/column targets and no trunk position can avoid it; routing every edge through the elbow router + optimizer + detour produces strictly better results (0 pierces on all good layouts). - Add _count_backwards and fold it into the side-reselect and detour cost tuples (crossings, pierces, backwards) so neither pass introduces an arrow that exits/enters against its port normal. _reselect_sides now also targets backwards edges, not just piercing ones. - QA harness uses the engine's own _seg_pierces_node so metric and optimizer agree on what counts as a pierce. Measured (crossings/pierces/backwards, 1720x800): ideal_v3: 0/0/0 ideal_v4: 1/2/0 ideal_v6: 2/0/0 agent_tree: 0/0/0 target_area: 0/0/0 (no regression) multi_agent raw: 9/5/0 (bad grouping; structural residual) --- skill/scripts/layout_qa.py | 17 ++---- skill/sdpm/layout/__init__.py | 108 +++++++++++++++++++++++++++------- 2 files changed, 93 insertions(+), 32 deletions(-) diff --git a/skill/scripts/layout_qa.py b/skill/scripts/layout_qa.py index 1126259f..b83788a2 100644 --- a/skill/scripts/layout_qa.py +++ b/skill/scripts/layout_qa.py @@ -29,6 +29,7 @@ _layout_collect, _layout_route_connections, _count_all_crossings, + _seg_pierces_node, _find_node, ) @@ -73,18 +74,10 @@ def build_root(): return nodes, groups, edges, root["_bindings"] -def _seg_pierces_rect(p1, p2, n, inset=4): - rx, ry = n["x"], n["y"] - rw, rh = n.get("width", 60), n.get("height", n.get("width", 60)) - x0, y0, x1, y1 = rx + inset, ry + inset, rx + rw - inset, ry + rh - inset - ax, ay = p1 - bx, by = p2 - if ax == bx: # vertical - return x0 < ax < x1 and min(ay, by) < y1 and max(ay, by) > y0 - if ay == by: # horizontal - return y0 < ay < y1 and min(ax, bx) < x1 and max(ax, bx) > x0 - # diagonal: bounding-box overlap as a coarse test - return min(ax, bx) < x1 and max(ax, bx) > x0 and min(ay, by) < y1 and max(ay, by) > y0 +def _seg_pierces_rect(p1, p2, n): + # Use the engine's own pierce definition so the QA metric and the + # optimizer's cost function agree on what counts as a pierce/graze. + return _seg_pierces_node(p1, p2, n) def _side_of(node, pt): diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 9657d5c3..4ce1a59e 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -1002,21 +1002,15 @@ def _layout_route_connections(connections, nodes, groups=None): sp = _port_point(src, src_side, port_indices[(i, conn["from"])], port_counts[(conn["from"], src_side)], label_h) tp = _port_point(dst, dst_side, port_indices[(i, conn["to"])], port_counts[(conn["to"], dst_side)], label_h) - # Fan-out right-side: use shared bend X for H-V-H pattern - # Skip if already a straight line (same Y) - src_id = conn["from"] - is_fanout_edge = False - if src_id in fanout_bend_x and src_side == "right" and abs(sp[1] - tp[1]) > 5 and dst_side == "left": - bend_x = round(fanout_bend_x[src_id]) - points = [sp, [bend_x, sp[1]], [bend_x, tp[1]], tp] - is_fanout_edge = True - else: - # Exclude src/dst nodes from obstacles to avoid self-avoidance - conn_obs = [o for o in obstacles if o.get("_node") not in (conn["from"], conn["to"])] - points = _elbow_path(sp, tp, src_side, dst_side, conn_obs) + # Route every edge with the standard elbow path. The downstream + # bend optimizer, side reselection, and detour passes shape each + # edge to minimize crossings/pierces — a shared fan-out trunk is + # no longer special-cased because, when targets sit on a row with + # an obstacle between them, a fixed trunk grazes that obstacle and + # no trunk position can avoid it (only a detour can). + conn_obs = [o for o in obstacles if o.get("_node") not in (conn["from"], conn["to"])] + points = _elbow_path(sp, tp, src_side, dst_side, conn_obs) edge_entry = {"from": conn["from"], "to": conn["to"], "label": conn.get("label", ""), "points": points} - if is_fanout_edge: - edge_entry["_fanout"] = True edges.append(edge_entry) # T8: Align bend positions for fan-out/fan-in only when "fan": "merge" is set @@ -1397,12 +1391,21 @@ def _count_all_crossings(edges): return count -_PIERCE_INSET = 4 +# Negative inset = a keep-out margin AROUND each icon. A line running along or +# just outside an icon's edge reads visually as touching/piercing it, so we +# count it as a pierce and push it away. Kept small so legitimate adjacent +# perpendicular stubs are not over-constrained. +_PIERCE_INSET = -6 _PIERCE_WEIGHT = 4 def _seg_pierces_node(p1, p2, n): - """True if axis-aligned segment p1-p2 passes through node n's interior.""" + """True if axis-aligned segment p1-p2 passes through (or grazes) node n. + + A negative _PIERCE_INSET expands the test rectangle beyond the icon so + segments running flush against an edge are flagged, matching what reads + visually as touching the icon. + """ rx, ry = n["x"], n["y"] rw, rh = n.get("width", 60), n.get("height", n.get("width", 60)) x0, y0 = rx + _PIERCE_INSET, ry + _PIERCE_INSET @@ -1435,6 +1438,62 @@ def _count_node_pierces(edges, nodes): return count +def _count_backwards(edges, nodes): + """Count edges whose first/last segment heads opposite to its port normal. + + A "backwards" segment leaves (or enters) an icon edge pointing back across + the icon — e.g. a bottom port whose first move is upward. The port side is + inferred from the endpoint's position on the node so this works regardless + of label offset (a bottom port sits below the icon's x-span). + """ + count = 0 + for e in edges: + pts = e["points"] + if len(pts) < 2: + continue + src = _find_node(nodes, e["from"]) + dst = _find_node(nodes, e["to"]) + for node, p_port, p_next, leaving in ( + (src, pts[0], pts[1], True), + (dst, pts[-1], pts[-2], False), + ): + if node is None: + continue + side = _port_side(node, p_port) + if side is None: + continue + # Outward normal for the port; the adjacent point must lie on the + # outward side (for a source) — i.e. not back across the icon. + if side == "right" and p_next[0] < p_port[0] - 2: + count += 1 + elif side == "left" and p_next[0] > p_port[0] + 2: + count += 1 + elif side == "bottom" and p_next[1] < p_port[1] - 2: + count += 1 + elif side == "top" and p_next[1] > p_port[1] + 2: + count += 1 + return count + + +def _port_side(node, pt): + """Infer which icon edge a port point sits on (label-offset aware).""" + x, y = pt + cx, cy = node["x"], node["y"] + w = node.get("width", 60) + h = node.get("height", w) + if cx - 2 <= x <= cx + w + 2: + if y >= cy + h - 2: + return "bottom" + if y <= cy + 2: + return "top" + if cy - 2 <= y <= cy + h + 2: + if x <= cx + 2: + return "left" + if x >= cx + w - 2: + return "right" + return None + + def _edge_free_bend(pts): """Return ('x'|'y', lo, hi) for the movable middle bend of a 4-point elbow. @@ -1594,6 +1653,11 @@ def _edge_pierces(e, nodes): return False +def _edge_backwards(e, nodes): + """True if edge e has a first/last segment heading against its port normal.""" + return _count_backwards([e], nodes) > 0 + + def _path_stability(pts): """Tie-break key: prefer fewer, shorter segments.""" length = sum( @@ -1618,7 +1682,8 @@ def _reselect_sides(edges, nodes, obstacles): for _ in range(_SIDE_RESELECT_PASSES): piercing = [ ei for ei, e in enumerate(edges) - if not e.get("_fanout") and len(e["points"]) >= 2 and _edge_pierces(e, nodes) + if not e.get("_fanout") and len(e["points"]) >= 2 + and (_edge_pierces(e, nodes) or _edge_backwards(e, nodes)) ] piercing.sort(key=lambda ei: (edges[ei]["from"], edges[ei]["to"])) committed = False @@ -1632,7 +1697,8 @@ def _reselect_sides(edges, nodes, obstacles): obs_excl = [o for o in obstacles if o.get("_node") not in (e["from"], e["to"])] label_h = 30 if src.get("label") else 0 - base = (_count_all_crossings(edges), _count_node_pierces(edges, nodes)) + base = (_count_all_crossings(edges), _count_node_pierces(edges, nodes), + _count_backwards(edges, nodes)) orig_pts = e["points"] best_pts = None best_score = base @@ -1648,7 +1714,8 @@ def _reselect_sides(edges, nodes, obstacles): if not _entry_exit_ok(cand, s_side, d_side): continue e["points"] = cand - score = (_count_all_crossings(edges), _count_node_pierces(edges, nodes)) + score = (_count_all_crossings(edges), _count_node_pierces(edges, nodes), + _count_backwards(edges, nodes)) e["points"] = orig_pts # Hard ceiling on crossings; lexicographic improvement; # tie-break on stability ONLY among equal-score candidates. @@ -1730,7 +1797,8 @@ def _detour_around_pierces(edges, nodes): return edges def cost(es): - return (_count_all_crossings(es), _count_node_pierces(es, nodes)) + return (_count_all_crossings(es), _count_node_pierces(es, nodes), + _count_backwards(es, nodes)) for _ in range(_DETOUR_PASSES): cur = cost(edges) From 893d79ead483f0338c0eca7a889761ef189e239f Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sun, 28 Jun 2026 12:37:49 +0900 Subject: [PATCH 36/79] tune(layout): widen icon keep-out margin to 9px Resolves borderline grazes where a connector ran ~6px from a non-endpoint icon (visually reads as touching). The optimizer now routes with >=10px clearance. No regression: ideal_v3 0/0, ideal_v6 2/0, agent_tree 0/0, target_area 0/0 (crossings/pierces). --- skill/sdpm/layout/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 4ce1a59e..f39f06c8 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -1395,7 +1395,7 @@ def _count_all_crossings(edges): # just outside an icon's edge reads visually as touching/piercing it, so we # count it as a pierce and push it away. Kept small so legitimate adjacent # perpendicular stubs are not over-constrained. -_PIERCE_INSET = -6 +_PIERCE_INSET = -9 _PIERCE_WEIGHT = 4 From 5eb0c269b452738b049111ee205cb270ef191245 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sun, 28 Jun 2026 13:31:33 +0900 Subject: [PATCH 37/79] fix(layout): stop misrouting fan-out targets as reverse-flow U-detours Two related fixes for arrows entering a node from the wrong edge: - Reverse-flow (U-shaped _detour_path) detection required only dx > dy*0.5, so a fan-out target sitting to the left AND well below the source (e.g. EventBridge -> Researcher) was wrongly routed down to the canvas bottom and back up into the target's bottom edge. Tighten to dx > dy*2 so only genuine same-row feedback loops get the U-detour; off-row targets use a normal elbow. - Fan-out side forcing now also requires the decided side's axis to match the target's dominant direction, so a target that is primarily to the side is not forced onto a vertical exit (and vice-versa). agent_tree's legitimate reverse edge (related_docs -> prod_nw) still routes correctly. ideal_v3 EB->researcher now enters from the right cleanly. --- skill/sdpm/layout/__init__.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index f39f06c8..6dbb4fa9 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -773,7 +773,11 @@ def _layout_route_connections(connections, nodes, groups=None): dst_cy = dst["y"] + dst.get("height", 60) / 2 dx = src["x"] - (dst["x"] + dst["width"]) dy = abs(dst_cy - src_cy) - if dx > dy * 0.5: + # Only treat as a reverse-flow (U-shaped detour) when the target is + # to the left AND roughly on the same row — a genuine feedback loop. + # If the target is also well above/below, a normal elbow routes it + # cleanly; the U-detour would wrap awkwardly into the wrong edge. + if dx > dy * 2: reverse_set.add(i) # Track decided sides per source node to ensure consistency for fan-out @@ -865,6 +869,12 @@ def _layout_route_connections(connections, nodes, groups=None): # source icon (e.g. forcing "right" when the target is to the # left). Check the target's actual direction relative to source. decided_is_backwards = False + # Whether the decided side's axis matches the target's DOMINANT + # direction. Forcing a vertical (top/bottom) exit toward a target + # that is primarily to the side (or vice-versa) makes the arrow + # wrap awkwardly around the target — so only force when the axes + # agree, even for fan-out sources. + decided_axis_matches_target = True if src and dst: s_cx = src["x"] + src.get("width", 60) / 2 s_cy = src["y"] + src.get("height", 60) / 2 @@ -878,8 +888,14 @@ def _layout_route_connections(connections, nodes, groups=None): decided_is_backwards = True elif decided == "top" and d_cy > s_cy: decided_is_backwards = True - apply_decided = (not decided_is_backwards) and ( - (src_id in fanout_sources) or (natural_axis == decided_axis) + adx = abs(d_cx - s_cx) + ady = abs(d_cy - s_cy) + target_axis = "h" if adx >= ady else "v" + decided_axis_matches_target = (target_axis == decided_axis) + apply_decided = ( + (not decided_is_backwards) + and decided_axis_matches_target + and ((src_id in fanout_sources) or (natural_axis == decided_axis)) ) if apply_decided: src_side = decided From 6ec311eea5ca2de671fdcd4636cfc9175437bc01 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sun, 28 Jun 2026 21:32:00 +0900 Subject: [PATCH 38/79] feat(layout): fan-merge trunk routing, alignment fix, port recentering Make "fan": "merge" a hard constraint that unifies fan-out/fan-in groups onto one shared port + trunk, and fix several routing defects surfaced while validating it. Engine (skill/sdpm/layout/__init__.py): - _rewrite_fan: rebuild merged groups onto a geometry-derived unified port and shared trunk; decide the shared side by majority vote; recompute spoke ports so every edge leaves/enters the trunk-facing edge (no more agents exiting left/right into a downward fan-in). Tag edges _fan_locked. - Guard _optimize_bends / _reselect_sides / _detour_around_pierces / _safe_separate_bends so they never disturb a locked fan trunk. - Fix _align_corresponding_leaves_x/y: only align leaves across groups that are actually stacked along the orthogonal axis (cluster by span overlap). Side-by-side same-shape groups no longer collapse onto one column/row. - Add _recenter_ports (T13): redistribute ports from the ACTUAL drawn sides so a lone edge centers and shared sides split evenly; fixes stale port_counts leaving horizontal stubs off the icon's mid-height. - Switch reselect/detour accept gates to a weighted defect score (pierce 1.5 > cross 1.0 > backwards 0.7) with a +1 crossing slack, so a line cutting through an icon is lifted off even at the cost of a crossing. - Evaluate reselect/detour candidates at their best post-bend-optimization shape (_optimize_single_bend, _optimize_jog_arm), and roll back whole passes that regress the global weighted score. QA (skill/scripts/layout_qa.py): - Add wirelength, overflow, aspect to measure(); score() = (overflow, weighted-defects, soft[wire+aspect]) so off-slide spill and degenerate defect trades are ranked correctly. Add skill/scripts/layout_search.py: greedy/exhaustive direction+align search that scores each candidate with the layout_qa objective. --- skill/scripts/layout_qa.py | 93 ++++- skill/scripts/layout_search.py | 176 +++++++++ skill/sdpm/layout/__init__.py | 629 +++++++++++++++++++++++++++------ 3 files changed, 795 insertions(+), 103 deletions(-) create mode 100644 skill/scripts/layout_search.py diff --git a/skill/scripts/layout_qa.py b/skill/scripts/layout_qa.py index b83788a2..be8c63a8 100644 --- a/skill/scripts/layout_qa.py +++ b/skill/scripts/layout_qa.py @@ -122,11 +122,17 @@ def measure(tree, target_w=1720, target_h=800): diagonals = [] bad_ports = [] backwards = [] + wirelength = 0.0 for e in edges: pts = e["points"] if len(pts) < 2: continue + + # total wire length (paths are axis-aligned, so Manhattan == polyline + # length). Minimizing this clusters connected icons together. + for k in range(len(pts) - 1): + wirelength += abs(pts[k][0] - pts[k + 1][0]) + abs(pts[k][1] - pts[k + 1][1]) ig = {e["from"], e["to"]} src = _find_node(nodes, e["from"]) dst = _find_node(nodes, e["to"]) @@ -180,13 +186,36 @@ def measure(tree, target_w=1720, target_h=800): if (not exp_horiz) and not vert: bad_ports.append((f"dst {e['to'].rsplit('.',1)[-1]}", ds, "not-vertical")) - return { + w, h = rb[2], rb[3] + diag = (w * w + h * h) ** 0.5 or 1.0 + # normalize wirelength by the canvas diagonal so the soft term is + # comparable across differently-sized candidate layouts. + wire_norm = wirelength / diag + aspect = (w / h) if h else 0.0 + + # Overflow: how far the laid-out bounds exceed the slide frame after + # scale-to-fit. A tall (vertical-root) layout can stop shrinking once its + # icons+labels hit their minimum size, leaving height > target_h — those + # icons render off-slide. This is unusable regardless of crossing count, + # so it ranks ABOVE crossings in the score. Expressed as a fraction of + # the target dimension (0 == fits). + overflow = 0.0 + if target_w and w > target_w: + overflow += (w - target_w) / target_w + if target_h and h > target_h: + overflow += (h - target_h) / target_h + + result = { "crossings": crossings, "pierces": len(pierces), "diagonals": len(diagonals), "bad_ports": len(bad_ports), "backwards": len(backwards), - "size": [round(rb[2]), round(rb[3])], + "overflow": round(overflow, 3), + "wirelength": round(wirelength), + "wire_norm": round(wire_norm, 3), + "aspect": round(aspect, 3), + "size": [round(w), round(h)], "detail": { "pierces": pierces, "diagonals": diagonals, @@ -194,6 +223,63 @@ def measure(tree, target_w=1720, target_h=800): "backwards": backwards, }, } + result["score"] = score(result) + return result + + +# Aspect ratio considered visually comfortable for a 16:9 slide body. +_ASPECT_LO, _ASPECT_HI = 1.4, 3.2 + +# Geometric-defect weights, combined into ONE additive layer so the search +# can't trade one defect class for a worse total of another (e.g. drive +# crossings to 0 by introducing 12 pierces). A pierce (arrow through a +# non-endpoint icon) reads worse than a crossing, so it is weighted heavier; +# a backwards segment is a softer wrongness than either. +_W_CROSS = 1.0 +_W_PIERCE = 1.5 +_W_BACK = 0.7 +_W_BADPORT = 0.5 + + +def score(m): + """Multi-objective score; lower is better. + + Two layers, compared lexicographically: + 1. overflow — does the layout spill off the slide frame? Off-slide + icons are unusable regardless of routing quality, so any + real overflow outranks everything below. + 2. defects — a single WEIGHTED SUM of geometric defects (crossings, + pierces, backwards, bad ports/diagonals). Combining them + additively (rather than as separate lexicographic tiers) + prevents the degenerate trade where the search zeroes one + defect class by inflating another. + Soft aesthetic terms (wire length, aspect penalty) break ties within the + defect layer. This is the "judge" that picks which position-shifted + candidate is actually good. + """ + aspect = m["aspect"] + if aspect < _ASPECT_LO: + aspect_pen = _ASPECT_LO - aspect + elif aspect > _ASPECT_HI: + aspect_pen = aspect - _ASPECT_HI + else: + aspect_pen = 0.0 + soft = m["wire_norm"] + 2.0 * aspect_pen + # Overflow bucketed to 0.1 (10% of a slide dimension) so small rounding + # jitter doesn't reorder otherwise-equal layouts, but any real off-slide + # spill outranks every geometric defect below it. + overflow_bucket = round(m.get("overflow", 0.0) * 10) + defects = ( + _W_CROSS * m["crossings"] + + _W_PIERCE * m["pierces"] + + _W_BACK * m["backwards"] + + _W_BADPORT * (m["bad_ports"] + m["diagonals"]) + ) + return ( + overflow_bucket, + round(defects, 2), + round(soft, 3), + ) def main(): @@ -214,6 +300,9 @@ def main(): print(f"crossings={result['crossings']} pierces={result['pierces']} " f"diagonals={result['diagonals']} bad_ports={result['bad_ports']} " f"backwards={result['backwards']} size={result['size']}") + print(f"overflow={result['overflow']} wirelength={result['wirelength']} " + f"wire_norm={result['wire_norm']} aspect={result['aspect']} " + f"score={result['score']}") for cat in ("pierces", "diagonals", "bad_ports", "backwards"): for d in result["detail"][cat]: print(f" {cat}: {d}") diff --git a/skill/scripts/layout_search.py b/skill/scripts/layout_search.py new file mode 100644 index 00000000..92a2567a --- /dev/null +++ b/skill/scripts/layout_search.py @@ -0,0 +1,176 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +"""Layout auto-direction search. + +Given a logical-structure JSON where the LLM has fixed only the *group +nesting* (which icons belong to which group, and the relative order of +groups), this searches the orientation/alignment degrees of freedom the +engine is allowed to vary and picks the configuration that minimizes the +multi-objective score from layout_qa.score(). + +Degrees of freedom searched (group membership + relative order preserved): + - direction : horizontal | vertical (per group, including the root) + - align : start | center | end (per group) +Child reordering is delegated to the engine's existing optimize_order(), +which measure() already invokes. + +Usage: + python3 layout_search.py [--out best.json] [--align] [--top N] +""" + +import argparse +import copy +import itertools +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from layout_qa import measure, score # noqa: E402 + +_DIRECTIONS = ("horizontal", "vertical") +_ALIGNS = ("start", "center", "end") +# Hard ceiling on raw config combinations before we fall back to a greedy +# coordinate-descent search instead of exhaustive enumeration. Each candidate +# runs the full scale/route/measure pipeline (~0.1-0.5s), so exhaustive is +# only viable for a handful of groups; greedy converges in O(rounds * n). +_EXHAUSTIVE_LIMIT = 64 + + +def _collect_groups(tree): + """Return (group_dict_refs) for the root and every nested group, in a + stable pre-order. A "group" is any dict carrying a children/nodes list. + The root tree itself counts as a group (its direction is the top-level + layout axis).""" + groups = [] + + def walk(node, is_root=False): + kids = node.get("children", node.get("nodes")) if isinstance(node, dict) else None + if kids is None: + return + groups.append(node) + for k in kids: + walk(k) + + walk(tree, is_root=True) + return groups + + +def _apply_config(tree, groups, dirs, aligns): + """Mutate a deep copy's groups with the given direction/align vectors.""" + for g, d in zip(groups, dirs): + g["direction"] = d + if aligns is not None: + for g, a in zip(groups, aligns): + g["align"] = a + return tree + + +def _eval(tree): + try: + m = measure(tree) + except Exception as exc: # noqa: BLE001 - degenerate configs can throw + return None, str(exc) + return m, None + + +def _exhaustive(base, search_align, top): + groups = _collect_groups(base) + n = len(groups) + dir_space = list(itertools.product(_DIRECTIONS, repeat=n)) + results = [] + for dirs in dir_space: + if search_align: + align_space = itertools.product(_ALIGNS, repeat=n) + else: + align_space = [None] + for aligns in align_space: + cand = copy.deepcopy(base) + cgroups = _collect_groups(cand) + _apply_config(cand, cgroups, dirs, aligns) + m, err = _eval(cand) + if m is None: + continue + results.append((m["score"], dirs, aligns, m, cand)) + results.sort(key=lambda r: r[0]) + return results[:top], len(results) + + +def _greedy(base, search_align, rounds=4): + """Coordinate descent: start from the input config, then repeatedly try + flipping one group's direction (and optionally cycling its align), + keeping any change that lowers the score. Converges fast on large trees + where exhaustive 2^N is infeasible.""" + cur = copy.deepcopy(base) + groups = _collect_groups(cur) + n = len(groups) + best_m, _ = _eval(cur) + best_score = best_m["score"] if best_m else (1e9,) + evals = 1 + for _ in range(rounds): + improved = False + for i in range(n): + for d in _DIRECTIONS: + aligns_opts = _ALIGNS if search_align else (None,) + for a in aligns_opts: + cand = copy.deepcopy(cur) + cg = _collect_groups(cand) + cg[i]["direction"] = d + if a is not None: + cg[i]["align"] = a + m, _ = _eval(cand) + evals += 1 + if m and m["score"] < best_score: + best_score, cur, best_m = m["score"], cand, m + improved = True + if not improved: + break + return [(best_score, None, None, best_m, cur)], evals + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("input") + ap.add_argument("--out", help="write best config to this path") + ap.add_argument("--align", action="store_true", help="also search align") + ap.add_argument("--top", type=int, default=5) + ap.add_argument("--greedy", action="store_true", help="force greedy search") + args = ap.parse_args() + + base = json.loads(Path(args.input).read_text(encoding="utf-8")) + groups = _collect_groups(base) + n = len(groups) + combos = (2 ** n) * ((3 ** n) if args.align else 1) + + base_m, _ = _eval(base) + print(f"groups={n} combos={combos} baseline score={base_m['score']}") + + use_greedy = args.greedy or combos > _EXHAUSTIVE_LIMIT + if use_greedy: + print(f"search=greedy (combos {combos} > {_EXHAUSTIVE_LIMIT})") + top, evals = _greedy(base, args.align) + else: + print("search=exhaustive") + top, evals = _exhaustive(base, args.align, args.top) + print(f"evaluated {evals} configs") + + for rank, (sc, dirs, aligns, m, cand) in enumerate(top): + print(f"#{rank+1} score={sc} cross={m['crossings']} pierce={m['pierces']} " + f"back={m['backwards']} wire={m['wire_norm']} aspect={m['aspect']}") + if dirs is not None: + cg = _collect_groups(cand) + for g, d in zip(cg, dirs): + gid = g.get("id", "_root") + orig = base_m and g.get("direction") + print(f" {gid}: {d}") + + best = top[0] + if args.out: + Path(args.out).write_text( + json.dumps(best[4], ensure_ascii=False, indent=2), encoding="utf-8") + print(f"wrote {args.out} (score={best[0]})") + + +if __name__ == "__main__": + main() diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 6dbb4fa9..d860648c 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -509,11 +509,54 @@ def sv(v): node["_padding"] = padding +def _ranges_overlap(lo1, hi1, lo2, hi2): + """True if the 1-D intervals [lo1,hi1] and [lo2,hi2] overlap.""" + return lo1 < hi2 and lo2 < hi1 + + +def _cluster_groups_by_axis_overlap(groups_with_leaves, axis): + """Partition same-count groups into clusters that genuinely share a row + (axis="x", i.e. their X spans overlap → stacked vertically) or a column + (axis="y", i.e. their Y spans overlap → placed side by side). + + Leaf alignment only makes sense WITHIN such a cluster. Aligning the Nth + leaf across groups that are laid out along the same axis we're aligning + (e.g. forcing the 1st leaf of four side-by-side horizontal groups to one X) + collapses them onto each other — the bug this guards against. + """ + # bindings: [x, y, w, h]. axis "x" → position x(0), size w(2); + # axis "y" → position y(1), size h(3). + pos_idx = 0 if axis == "x" else 1 + size_idx = 2 if axis == "x" else 3 + items = [] + for group, leaves in groups_with_leaves: + b = group["_bindings"] + lo = b[pos_idx] + hi = b[pos_idx] + b[size_idx] + items.append((lo, hi, group, leaves)) + items.sort(key=lambda it: it[0]) + + clusters = [] + for lo, hi, group, leaves in items: + placed = False + for cluster in clusters: + # cluster shares the span if it overlaps ANY member + if any(_ranges_overlap(lo, hi, c_lo, c_hi) for c_lo, c_hi, _, _ in cluster): + cluster.append((lo, hi, group, leaves)) + placed = True + break + if not placed: + clusters.append([(lo, hi, group, leaves)]) + return [[(g, lv) for _, _, g, lv in cluster] for cluster in clusters] + + def _align_corresponding_leaves_y(ordered): - """Align Y of corresponding leaves across all vertical groups in the subtree. + """Align Y of corresponding leaves across vertical groups that sit SIDE BY + SIDE (their Y spans overlap). Groups stacked vertically must NOT be aligned + to each other — that would collapse them onto one row. - Collects all vertical groups (at any depth) with the same leaf count and - aligns their Nth leaves to the same Y center. + Collects all vertical groups (at any depth) with the same leaf count, then + aligns Nth leaves to the same Y center only within each side-by-side cluster. """ vertical_groups = [] for child in ordered: @@ -530,21 +573,28 @@ def _align_corresponding_leaves_y(ordered): for groups_with_same_count in by_count.values(): if len(groups_with_same_count) < 2: continue - leaf_count = len(groups_with_same_count[0][1]) - for row_idx in range(leaf_count): - row_leaves = [leaves[row_idx] for _, leaves in groups_with_same_count] - centers = [leaf["_bindings"][1] + leaf["_bindings"][3] // 2 for leaf in row_leaves] - target_cy = max(centers) - for leaf in row_leaves: - b = leaf["_bindings"] - current_cy = b[1] + b[3] // 2 - dy = target_cy - current_cy - if dy != 0: - _layout_translate(leaf, 0, dy) + # Only align groups whose Y spans overlap (truly side by side). + for cluster in _cluster_groups_by_axis_overlap(groups_with_same_count, "y"): + if len(cluster) < 2: + continue + leaf_count = len(cluster[0][1]) + for row_idx in range(leaf_count): + row_leaves = [leaves[row_idx] for _, leaves in cluster] + centers = [leaf["_bindings"][1] + leaf["_bindings"][3] // 2 for leaf in row_leaves] + target_cy = max(centers) + for leaf in row_leaves: + b = leaf["_bindings"] + current_cy = b[1] + b[3] // 2 + dy = target_cy - current_cy + if dy != 0: + _layout_translate(leaf, 0, dy) def _align_corresponding_leaves_x(ordered): - """Align X of corresponding leaves across all horizontal groups in the subtree.""" + """Align X of corresponding leaves across horizontal groups that are STACKED + VERTICALLY (their X spans overlap). Groups placed side by side must NOT be + aligned to each other — that would collapse them onto one column. + """ horizontal_groups = [] for child in ordered: _collect_horizontal_groups(child, horizontal_groups) @@ -560,17 +610,21 @@ def _align_corresponding_leaves_x(ordered): for groups_with_same_count in by_count.values(): if len(groups_with_same_count) < 2: continue - leaf_count = len(groups_with_same_count[0][1]) - for col_idx in range(leaf_count): - col_leaves = [leaves[col_idx] for _, leaves in groups_with_same_count] - centers = [leaf["_bindings"][0] + leaf["_bindings"][2] // 2 for leaf in col_leaves] - target_cx = max(centers) - for leaf in col_leaves: - b = leaf["_bindings"] - current_cx = b[0] + b[2] // 2 - dx = target_cx - current_cx - if dx != 0: - _layout_translate(leaf, dx, 0) + # Only align groups whose X spans overlap (truly stacked vertically). + for cluster in _cluster_groups_by_axis_overlap(groups_with_same_count, "x"): + if len(cluster) < 2: + continue + leaf_count = len(cluster[0][1]) + for col_idx in range(leaf_count): + col_leaves = [leaves[col_idx] for _, leaves in cluster] + centers = [leaf["_bindings"][0] + leaf["_bindings"][2] // 2 for leaf in col_leaves] + target_cx = max(centers) + for leaf in col_leaves: + b = leaf["_bindings"] + current_cx = b[0] + b[2] // 2 + dx = target_cx - current_cx + if dx != 0: + _layout_translate(leaf, dx, 0) def _collect_vertical_groups(node, out): @@ -1029,12 +1083,17 @@ def _layout_route_connections(connections, nodes, groups=None): edge_entry = {"from": conn["from"], "to": conn["to"], "label": conn.get("label", ""), "points": points} edges.append(edge_entry) - # T8: Align bend positions for fan-out/fan-in only when "fan": "merge" is set - _align_fan_bends(edges, conn_sides, connections) + # T8: Merge fan-out/fan-in groups onto a unified port + shared trunk when + # "fan": "merge" is set. This is treated as a HARD CONSTRAINT: the merged + # edges are tagged `_fan_locked` and every downstream pass leaves their + # trunks untouched. Crossing avoidance for merged fans comes from placement + # (the order/direction search) and from routing the OTHER edges around the + # fixed trunks — not from un-merging them. + _align_fan_bends(edges, conn_sides, connections, nodes) # T9: Safe bend separation — shift overlapping vertical bends apart # while preserving axis-alignment (only move X of vertical segments, - # never touch start/end points). + # never touch start/end points). Skips locked fan trunks. _safe_separate_bends(edges) # T10: Bend optimization — slide each free (middle) bend of an elbow path @@ -1052,22 +1111,156 @@ def _layout_route_connections(connections, nodes, groups=None): # segments (unlike a detour), so it cannot cause a crossing blow-up, and # endpoints stay perpendicular because every port comes from _port_point. obstacles_re = [o for o in obstacles if o.get("_node")] + # Guard the whole reselect pass on the global weighted defect score: the + # per-edge slack (allowing +1 crossing to clear a pierce) is locally sound + # but can accumulate across edges into a net-worse layout. Roll back if the + # weighted total (crossings + 1.5*pierces + 0.7*backwards) regresses. + _resel_before = _defect_weight((_count_all_crossings(edges), + _count_node_pierces(edges, nodes), + _count_backwards(edges, nodes))) + _resel_snapshot = [list(map(list, e["points"])) for e in edges] _reselect_sides(edges, nodes, obstacles_re) + _resel_after = _defect_weight((_count_all_crossings(edges), + _count_node_pierces(edges, nodes), + _count_backwards(edges, nodes))) + if _resel_after > _resel_before: + for e, pts in zip(edges, _resel_snapshot): + e["points"] = pts _optimize_bends(edges, nodes) # T12: Obstacle detour — for pierces that no side/port choice can clear # (e.g. an icon stacked directly between source and target in the same # column), splice an axis-aligned jog around the obstacle. Each candidate - # is judged against the full live edge set with a lexicographic - # (crossings, pierces) gate: a jog is committed only if it does not raise - # the global crossing count and strictly lowers pierces. The jog is + # is judged by the weighted defect score (pierce 1.5 > cross 1.0), so a jog + # may add a crossing to lift a line off an icon it cuts through. The jog is # spliced into the interior of a segment, so endpoints never move and no - # diagonal is ever produced. + # diagonal is ever produced. Guarded on the global weighted total so the + # per-edge slack can't accumulate into a net-worse layout. + _det_before = _defect_weight((_count_all_crossings(edges), + _count_node_pierces(edges, nodes), + _count_backwards(edges, nodes))) + _det_snapshot = [list(map(list, e["points"])) for e in edges] _detour_around_pierces(edges, nodes) + _det_after = _defect_weight((_count_all_crossings(edges), + _count_node_pierces(edges, nodes), + _count_backwards(edges, nodes))) + if _det_after > _det_before: + for e, pts in zip(edges, _det_snapshot): + e["points"] = pts + + # T13: Port recentering — by now each edge's actual entry/exit side may + # differ from the side that seeded port_counts (fan merge, side reselect, + # and elbow re-picks all move endpoints). That stale count left, e.g., a + # lone right-side edge sharing a "2 ports" slot and sitting off-center. + # Recompute the real per-(node, side) usage from geometry and redistribute + # the ports evenly along each edge, snapping the adjacent bend so the stub + # stays perpendicular. Fan-locked endpoints are fixed and excluded. + # Guarded per (node, side) group: each redistribution is kept only if it + # does not increase global crossings — centering a port can occasionally + # re-introduce a crossing that bend-opt had removed. + _recenter_ports(edges, nodes) return edges +_PORT_EPS = 4 + + +def _recenter_ports(edges, nodes): + """Evenly redistribute each node-edge's ports using the ACTUAL drawn sides. + + Endpoints are the only points moved (plus the immediately adjacent bend, to + keep the first/last stub axis-aligned). A single edge on a side lands dead + center; N edges split the side into N+1 even slots, ordered by the position + of their opposite end so they don't cross at the port. This corrects the + off-center stubs left by stale port_counts after fan/side changes. + """ + def side_of(node, pt): + x, y = pt + nx, ny = node["x"], node["y"] + w = node.get("width", 60) + h = node.get("height", w) + if nx - _PORT_EPS <= x <= nx + w + _PORT_EPS: + if y >= ny + h - _PORT_EPS: + return "bottom" + if y <= ny + _PORT_EPS: + return "top" + if ny - _PORT_EPS <= y <= ny + h + _PORT_EPS: + if x <= nx + _PORT_EPS: + return "left" + if x >= nx + w - _PORT_EPS: + return "right" + return None + + # Gather endpoints to move: (node, side) -> list of (edge, end_index, opp_pt) + groups = {} + for e in edges: + pts = e["points"] + if len(pts) < 2 or e.get("_fanout"): + continue + for end_idx, nid in ((0, e["from"]), (-1, e["to"])): + # Skip the locked end of a fan edge (its port is the shared trunk port). + if e.get("_fan_locked"): + lock = e["_fan_locked"] + # fan_out: shared port at start; fan_in: shared port at end. + if (lock["mode"] == "fan_out" and end_idx == 0) or \ + (lock["mode"] == "fan_in" and end_idx == -1): + continue + node = _find_node(nodes, nid) + if node is None: + continue + s = side_of(node, pts[end_idx]) + if s is None: + continue + opp = pts[-1] if end_idx == 0 else pts[0] + groups.setdefault((nid, s), []).append((e, end_idx, opp, node)) + + for (nid, side), members in groups.items(): + node = members[0][3] + nx, ny = node["x"], node["y"] + w = node.get("width", 60) + h = node.get("height", w) + label_h = 30 if node.get("label") else 0 + n = len(members) + # Order members along the edge by the coordinate of their opposite end, + # so adjacent ports connect to adjacent targets (minimizes self-cross). + if side in ("left", "right"): + members.sort(key=lambda m: m[2][1]) # by opposite Y + else: + members.sort(key=lambda m: m[2][0]) # by opposite X + + # Snapshot the edges this group touches so we can roll back if centering + # the ports happens to add a crossing the optimizer had removed. + touched = {id(e): list(map(list, e["points"])) for e, _, _, _ in members} + before = _count_all_crossings(edges) + + for slot, (e, end_idx, opp, _node) in enumerate(members): + t = (slot + 1) / (n + 1) + pts = e["points"] + if side == "right": + newp = [nx + w, round(ny + h * t)] + elif side == "left": + newp = [nx, round(ny + h * t)] + elif side == "bottom": + newp = [round(nx + w * t), ny + h + label_h] + else: # top + newp = [round(nx + w * t), ny] + # Move the endpoint and snap the adjacent bend to keep the stub + # perpendicular: for a left/right port the stub is horizontal, so + # the neighbor shares the new Y; for top/bottom it shares the new X. + adj_idx = 1 if end_idx == 0 else len(pts) - 2 + if 0 <= adj_idx < len(pts): + if side in ("left", "right"): + pts[adj_idx] = [pts[adj_idx][0], newp[1]] + else: + pts[adj_idx] = [newp[0], pts[adj_idx][1]] + pts[end_idx] = newp + + if _count_all_crossings(edges) > before: + for e, _, _, _ in members: + e["points"] = touched[id(e)] + + def _safe_separate_bends(edges): """Separate overlapping vertical bends by shifting their X position. @@ -1084,7 +1277,7 @@ def _safe_separate_bends(edges): v_segs = [] for ei, e in enumerate(edges): pts = e["points"] - if e.get("_fanout"): + if e.get("_fanout") or e.get("_fan_locked"): continue for k in range(len(pts) - 1): if abs(pts[k][0] - pts[k+1][0]) <= 3 and abs(pts[k][1] - pts[k+1][1]) > 10: @@ -1311,41 +1504,47 @@ def _heuristic_port_sort(conn_indices, nid, side, port_indices, conn_sides, conn _FAN_SPREAD_LIMIT = 600 -def _align_fan_bends(edges, conn_sides, connections): +def _align_fan_bends(edges, conn_sides, connections, nodes=None): """Align bend positions and merge ports for fan-out and fan-in groups. - Only activates when connections have "fan": "merge" set. - Default behavior keeps ports separate (split). + Only activates when connections have "fan": "merge" set. A merged group is + a hard constraint: all edges sharing the same source (fan-out) or the same + target (fan-in) are forced onto ONE unified port and a shared trunk bend, + regardless of which icon edge the router originally chose. The side is + decided by majority vote across the group's edges so a single odd-side edge + no longer splinters the group (the previous (from, src_side) keying did). + + After this runs, each rewritten edge carries `_fan_locked` so downstream + optimizers (bend slide, side reselect, detour) leave its trunk alone — the + merge is the spec, and crossing reduction must work AROUND it, not undo it. """ - # Fan-out: same src + same src_side, only if all connections in the group have fan=merge + # Fan-out: group purely by source node (side decided later by vote). src_groups = {} for i, (src, dst, src_side, dst_side) in enumerate(conn_sides): - if src is None or len(edges[i]["points"]) <= 2: + if src is None or len(edges[i]["points"]) < 2: continue if connections[i].get("fan") != "merge": continue - k = (connections[i]["from"], src_side) - src_groups.setdefault(k, []).append(i) + src_groups.setdefault(connections[i]["from"], []).append(i) for indices in src_groups.values(): if len(indices) < 2: continue - _rewrite_fan(edges, conn_sides, indices, mode="fan_out") + _rewrite_fan(edges, conn_sides, indices, mode="fan_out", nodes=nodes) - # Fan-in: same dst + same dst_side, only if all connections in the group have fan=merge + # Fan-in: group purely by target node. dst_groups = {} for i, (src, dst, src_side, dst_side) in enumerate(conn_sides): - if src is None or len(edges[i]["points"]) <= 2: + if src is None or len(edges[i]["points"]) < 2: continue if connections[i].get("fan") != "merge": continue - k = (connections[i]["to"], dst_side) - dst_groups.setdefault(k, []).append(i) + dst_groups.setdefault(connections[i]["to"], []).append(i) for indices in dst_groups.values(): if len(indices) < 2: continue - _rewrite_fan(edges, conn_sides, indices, mode="fan_in") + _rewrite_fan(edges, conn_sides, indices, mode="fan_in", nodes=nodes) _MAX_RESOLVE_ITERATIONS = 30 @@ -1553,6 +1752,11 @@ def cost(es): for _ in range(_BEND_OPT_PASSES): improved = False for e in edges: + # A locked fan trunk must not move — sliding its free bend is what + # shifts the shared trunk line, which would break the merge the + # user explicitly requested. Leave it fixed. + if e.get("_fan_locked"): + continue # Fan-out edges start on a shared trunk but are still free to be # refined individually; optimizing them too reduces crossings # without breaking axis-alignment (their middle bend is free). @@ -1590,10 +1794,62 @@ def cost(es): return edges +def _optimize_single_bend(cand_pts, edge, edges, nodes): + """Return cand_pts with its single free elbow bend slid to lowest weighted + cost, evaluated against the live edge set (edge temporarily holds cand_pts). + + Used when judging a reselect candidate so we compare its BEST shape, not the + arbitrary mid-bend the router emits. Only the two middle points of a 4-point + VHV/HVH path move, along the trunk axis, so endpoints stay port-anchored. + """ + fv = _edge_free_bend(cand_pts) + if not fv: + return cand_pts + axis, lo, hi = fv + lo, hi = min(lo, hi), max(lo, hi) + if hi - lo < 2 * _BEND_OPT_INSET: + return cand_pts + idx = 0 if axis == "x" else 1 + saved = edge["points"] + best = [list(p) for p in cand_pts] + edge["points"] = best + best_w = _defect_weight((_count_all_crossings(edges), + _count_node_pierces(edges, nodes), + _count_backwards(edges, nodes))) + v = int(lo) + _BEND_OPT_INSET + while v < int(hi) - _BEND_OPT_INSET: + trial = [list(p) for p in cand_pts] + trial[1][idx] = v + trial[2][idx] = v + edge["points"] = trial + w = _defect_weight((_count_all_crossings(edges), + _count_node_pierces(edges, nodes), + _count_backwards(edges, nodes))) + if w < best_w: + best_w = w + best = trial + v += _BEND_OPT_STEP + edge["points"] = saved + return best + + _SIDE_RESELECT_PASSES = 6 # (port_index, port_count): center, then 1/4, 1/2, 3/4 along the edge. _PORT_TRIALS = [(0, 1), (0, 3), (1, 3), (2, 3)] +# Weights for comparing routing defects when reselecting sides. A pierce is the +# most visually damaging (a line cutting through an icon), so it outweighs a +# crossing; a backwards stub is the mildest. These mirror layout_qa.score(). +_DEFECT_W = (1.0, 1.5, 0.7) # (crossings, pierces, backwards) +# How many extra crossings a side change may introduce to clear a pierce. One +# crossing is an acceptable price to stop a line cutting through an icon. +_RESELECT_CROSS_SLACK = 1 + + +def _defect_weight(score): + """Weighted scalar of a (crossings, pierces, backwards) tuple; lower better.""" + return sum(w * s for w, s in zip(_DEFECT_W, score)) + def _candidate_side_pairs(src, dst): """Geometrically sane (src_side, dst_side) pairs; natural pair first. @@ -1698,7 +1954,8 @@ def _reselect_sides(edges, nodes, obstacles): for _ in range(_SIDE_RESELECT_PASSES): piercing = [ ei for ei, e in enumerate(edges) - if not e.get("_fanout") and len(e["points"]) >= 2 + if not e.get("_fanout") and not e.get("_fan_locked") + and len(e["points"]) >= 2 and (_edge_pierces(e, nodes) or _edge_backwards(e, nodes)) ] piercing.sort(key=lambda ei: (edges[ei]["from"], edges[ei]["to"])) @@ -1729,24 +1986,38 @@ def _reselect_sides(edges, nodes, obstacles): continue if not _entry_exit_ok(cand, s_side, d_side): continue + # Judge the candidate by its BEST achievable shape: slide + # its free trunk bend to the lowest-cost position before + # scoring. A bottom→top reroute past a row of icons looks + # bad at the default mid-bend but clears everything once + # the trunk is nudged into the gap — evaluate THAT. + cand = _optimize_single_bend(cand, e, edges, nodes) e["points"] = cand score = (_count_all_crossings(edges), _count_node_pierces(edges, nodes), _count_backwards(edges, nodes)) e["points"] = orig_pts - # Hard ceiling on crossings; lexicographic improvement; - # tie-break on stability ONLY among equal-score candidates. - if score[0] > base[0]: + # A pierce (line through a non-endpoint icon) reads worse + # than a crossing, so judge candidates by a WEIGHTED total + # (pierce 1.5 > cross 1.0 > backwards 0.7) rather than a + # strict crossings-first ceiling. This lets a still-piercing + # edge clear the icon even when doing so adds one crossing, + # matching the layout_qa objective. A guard still rejects + # trades that pile on crossings (more than +_RESELECT_CROSS_SLACK). + if score[0] > base[0] + _RESELECT_CROSS_SLACK: continue better = ( - score < best_score - or (best_pts is not None and score == best_score + _defect_weight(score) < _defect_weight(best_score) + or (best_pts is not None + and _defect_weight(score) == _defect_weight(best_score) and _path_stability(cand) < _path_stability(best_pts)) ) if better: best_score = score best_pts = cand - if best_pts is not None and best_score < base and best_score[0] <= base[0]: + if (best_pts is not None + and _defect_weight(best_score) < _defect_weight(base) + and best_score[0] <= base[0] + _RESELECT_CROSS_SLACK): e["points"] = best_pts committed = True @@ -1757,6 +2028,60 @@ def _reselect_sides(edges, nodes, obstacles): _DETOUR_FACE_MARGIN = 18 _DETOUR_PASSES = 6 +_JOG_ARM_STEP = 12 + + +def _optimize_jog_arm(cand, k, edge, edges, nodes): + """Slide a freshly-spliced jog arm to its lowest-cost parallel position. + + A jog splices 4 points at index k+1: [arm_a, corner_a, corner_b, arm_b]. + The two corners share one free coordinate (the arm's offset from the + pierced segment) — x for a jog off a vertical segment, y for a horizontal + one. The raw candidate hugs the obstacle face; sliding the arm outward can + clear other edges it would otherwise cross. We scan a range of offsets and + keep the one with the lowest weighted defect, evaluated against the live + edge set. Endpoints (arm_a, arm_b) stay put, so the splice remains interior + and axis-aligned. + """ + if len(cand) < k + 5: + return cand + ca, cb = cand[k + 2], cand[k + 3] + # Determine the free axis: corners share x (vertical-seg jog) or y (horiz). + if ca[0] == cb[0]: + axis = 0 # corners share X — slide X + elif ca[1] == cb[1]: + axis = 1 # corners share Y — slide Y + else: + return cand # not a clean bracket + + def weighted(pts_override): + saved = edge["points"] + edge["points"] = pts_override + s = (_count_all_crossings(edges), _count_node_pierces(edges, nodes), + _count_backwards(edges, nodes)) + edge["points"] = saved + return _defect_weight(s) + + base_val = ca[axis] + best = cand + best_w = weighted(cand) + # Search outward on both sides of the current arm offset. + for delta in range(-120, 121, _JOG_ARM_STEP): + if delta == 0: + continue + v = base_val + delta + trial = [list(p) for p in cand] + trial[k + 2][axis] = v + trial[k + 3][axis] = v + if not _is_axis_aligned(trial): + continue + # The arm must not now pierce the very obstacle it was meant to clear, + # nor any other — that is captured by the pierce term in the weight. + w = weighted(trial) + if w < best_w: + best_w = w + best = trial + return best def _jog_candidates(seg_a, seg_b, n): @@ -1823,6 +2148,11 @@ def cost(es): improved = False for e in edges: + # A locked fan trunk must keep its shape — splicing a jog into it + # would bend the shared trunk and break the merge. Skip it; other + # edges detour around it instead. + if e.get("_fan_locked"): + continue # Fan-out edges are eligible: a jog around an obstacle does not # break the shared-trunk concept, and the global gate below only # commits it when it strictly helps. @@ -1846,19 +2176,31 @@ def cost(es): cand = pts[:k + 1] + repl[1:-1] + pts[k + 1:] if not _is_axis_aligned(cand): continue + # The raw jog hugs the obstacle's face; that arm position + # may cross other edges. Slide the jog arm to its best + # position FIRST, then judge — mirrors evaluating a config + # by its post-bend-optimization quality, not its raw form. + cand = _optimize_jog_arm(cand, k, e, edges, nodes) saved = e["points"] e["points"] = cand score = cost(edges) e["points"] = saved - if score[0] > base[0]: + # Judge by weighted defect (pierce 1.5 > cross 1.0): a jog + # may add up to _RESELECT_CROSS_SLACK crossings to lift a + # line off an icon it cuts through, which reads far worse + # than a crossing. Mirrors _reselect_sides. + if score[0] > base[0] + _RESELECT_CROSS_SLACK: continue - if score < best_score or ( - best_pts is not None and score == best_score + if _defect_weight(score) < _defect_weight(best_score) or ( + best_pts is not None + and _defect_weight(score) == _defect_weight(best_score) and _path_stability(cand) < _path_stability(best_pts) ): best_score = score best_pts = cand - if best_pts is not None and best_score < base and best_score[0] <= base[0]: + if (best_pts is not None + and _defect_weight(best_score) < _defect_weight(base) + and best_score[0] <= base[0] + _RESELECT_CROSS_SLACK): e["points"] = best_pts improved = True @@ -2293,63 +2635,148 @@ def _update_bend_x(points, old_x, new_x): _FAN_BEND_MARGIN = 30 -def _rewrite_fan(edges, conn_sides, indices, mode): - """Rewrite fan-out/fan-in elbows: unified trunk port + bend near targets.""" - _, _, src_side, dst_side = conn_sides[indices[0]] - vertical = (src_side if mode == "fan_out" else dst_side) in ("top", "bottom") +def _fan_side_vote(edges, conn_sides, indices, mode): + """Pick the single shared side for a fan group by majority vote. - # Check spread limit - if mode == "fan_out": - targets = [edges[i]["points"][-1] for i in indices] - else: - targets = [edges[i]["points"][0] for i in indices] - coords = [t[0 if vertical else 1] for t in targets] - if max(coords) - min(coords) > _FAN_SPREAD_LIMIT: + The hub end (src for fan-out, dst for fan-in) must agree on ONE side so + all edges leave/enter through one unified port. The router may have chosen + different sides per edge; we take the most common, tie-broken by a stable + preference order. + """ + pref = {"right": 0, "left": 1, "bottom": 2, "top": 3} + votes = {} + for i in indices: + _, _, src_side, dst_side = conn_sides[i] + side = src_side if mode == "fan_out" else dst_side + if side: + votes[side] = votes.get(side, 0) + 1 + if not votes: + return "right" + return sorted(votes.items(), key=lambda kv: (-kv[1], pref.get(kv[0], 9)))[0][0] + + +def _rewrite_fan(edges, conn_sides, indices, mode, nodes=None): + """Force a fan-out/fan-in group onto a unified port and a shared trunk. + + The merge is a hard constraint (the LLM asked for it), so we rebuild every + edge in the group from scratch as a clean 4-point elbow: + - one shared port on the hub node (computed from node geometry, centered), + - a shared trunk coordinate (all edges bend at the same line), + - the spoke then peels off to each individual target/source. + Edges that had become detours (len>4) are rebuilt too. Each edge is tagged + `_fan_locked` with the trunk axis/value so downstream optimizers don't undo + the alignment. + """ + if not indices: return - - # Pre-compute unified port center - if mode == "fan_out": - all_ports = [edges[j]["points"][0] for j in indices] + side = _fan_side_vote(edges, conn_sides, indices, mode) + vertical = side in ("top", "bottom") + + # Resolve the hub node (shared end) and its geometry for an exact port. + hub_id = edges[indices[0]]["from"] if mode == "fan_out" else edges[indices[0]]["to"] + hub = _find_node(nodes, hub_id) if nodes else None + + # Unified port point on the hub edge, centered along that edge. + if hub is not None: + hx, hy, hw, hh = hub["x"], hub["y"], hub["width"], hub["height"] + label_h = 30 if hub.get("label") else 0 + if side == "right": + port = [hx + hw, hy + hh // 2] + elif side == "left": + port = [hx, hy + hh // 2] + elif side == "bottom": + port = [hx + hw // 2, hy + hh + label_h] + else: # top + port = [hx + hw // 2, hy] else: - all_ports = [edges[j]["points"][-1] for j in indices] + # Fall back to averaging the existing ports if geometry is unavailable. + ends = [edges[j]["points"][0] if mode == "fan_out" else edges[j]["points"][-1] + for j in indices] + port = [sum(p[0] for p in ends) // len(ends), sum(p[1] for p in ends) // len(ends)] + + # Shared trunk coordinate: a line just outside the hub port, on the spoke + # side, where all edges turn toward their individual targets. + spoke_ends = [edges[j]["points"][-1] if mode == "fan_out" else edges[j]["points"][0] + for j in indices] if vertical: - port_center = sum(p[0] for p in all_ports) // len(all_ports) + # trunk is a horizontal line at y = trunk_v; pick it between the hub + # port and the nearest spoke end so the trunk sits in the gap. + spoke_vs = [p[1] for p in spoke_ends] + if side == "bottom": + nearest = min(spoke_vs) + trunk_v = max(port[1] + _FAN_BEND_MARGIN, (port[1] + nearest) // 2) + else: # top + nearest = max(spoke_vs) + trunk_v = min(port[1] - _FAN_BEND_MARGIN, (port[1] + nearest) // 2) else: - port_center = sum(p[1] for p in all_ports) // len(all_ports) + spoke_hs = [p[0] for p in spoke_ends] + if side == "right": + nearest = min(spoke_hs) + trunk_v = max(port[0] + _FAN_BEND_MARGIN, (port[0] + nearest) // 2) + else: # left + nearest = max(spoke_hs) + trunk_v = min(port[0] - _FAN_BEND_MARGIN, (port[0] + nearest) // 2) + + # The spoke nodes (the N individual ends) must ALSO leave/enter through a + # consistent edge — the one facing the trunk. A fan-in to a trunk BELOW the + # agents means every agent exits its BOTTOM edge (not whichever side the + # router first picked, which left planner exiting "right" and coder "left"). + # The spoke side is the side facing the trunk: opposite the hub side for the + # spoke's own port normal. + def spoke_port(node, sside): + nx, ny, nw, nh = node["x"], node["y"], node["width"], node["height"] + nlabel_h = 30 if node.get("label") else 0 + if sside == "bottom": + return [nx + nw // 2, ny + nh + nlabel_h] + if sside == "top": + return [nx + nw // 2, ny] + if sside == "right": + return [nx + nw, ny + nh // 2] + return [nx, ny + nh // 2] # left for i in indices: + # spoke node = the per-edge individual end (target for fan-out, source for fan-in) + spoke_id = edges[i]["to"] if mode == "fan_out" else edges[i]["from"] + spoke_node = _find_node(nodes, spoke_id) if nodes else None pts = edges[i]["points"] - if len(pts) < 4: - continue - src_pt = list(pts[0]) - dst_pt = list(pts[-1]) + + # Decide which spoke edge faces the trunk. The trunk is a line on the + # `side` axis relative to the hub; the spoke must exit toward it. + if vertical: + # trunk is a horizontal line at y=trunk_v; spoke exits bottom if it + # sits above the trunk, else top. + ref = (spoke_node["y"] + spoke_node["height"] // 2) if spoke_node else pts[0][1] + s_side = "bottom" if trunk_v >= ref else "top" + else: + ref = (spoke_node["x"] + spoke_node["width"] // 2) if spoke_node else pts[0][0] + s_side = "right" if trunk_v >= ref else "left" + + if spoke_node is not None: + spoke_pt = spoke_port(spoke_node, s_side) + else: + spoke_pt = list(pts[-1] if mode == "fan_out" else pts[0]) if mode == "fan_out": + tgt = spoke_pt if vertical: - bend_y = dst_pt[1] - _FAN_BEND_MARGIN - pts[0] = [port_center, src_pt[1]] - pts[1] = [port_center, bend_y] - pts[2] = [dst_pt[0], bend_y] - pts[3] = [dst_pt[0], dst_pt[1]] + edges[i]["points"] = [list(port), [port[0], trunk_v], [tgt[0], trunk_v], tgt] else: - bend_x = dst_pt[0] - _FAN_BEND_MARGIN - pts[0] = [src_pt[0], port_center] - pts[1] = [bend_x, port_center] - pts[2] = [bend_x, dst_pt[1]] - pts[3] = [dst_pt[0], dst_pt[1]] - else: + edges[i]["points"] = [list(port), [trunk_v, port[1]], [trunk_v, tgt[1]], tgt] + else: # fan_in + srcp = spoke_pt if vertical: - bend_y = src_pt[1] + _FAN_BEND_MARGIN - pts[0] = [src_pt[0], src_pt[1]] - pts[1] = [src_pt[0], bend_y] - pts[2] = [port_center, bend_y] - pts[3] = [port_center, dst_pt[1]] + edges[i]["points"] = [srcp, [srcp[0], trunk_v], [port[0], trunk_v], list(port)] else: - bend_x = src_pt[0] + _FAN_BEND_MARGIN - pts[0] = [src_pt[0], src_pt[1]] - pts[1] = [bend_x, src_pt[1]] - pts[2] = [bend_x, port_center] - pts[3] = [dst_pt[0], port_center] + edges[i]["points"] = [srcp, [trunk_v, srcp[1]], [trunk_v, port[1]], list(port)] + # Lock the trunk: downstream optimizers must not move the shared + # coordinate. The spoke (3rd point toward the individual end) stays + # free to be nudged if needed. + edges[i]["_fan_locked"] = { + "mode": mode, + "axis": "y" if vertical else "x", + "trunk": trunk_v, + "port": list(port), + } def _find_group_for(node_id, node_group): From c1366a34df421fa4163a6faaa695e3a876b9969a Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Sun, 28 Jun 2026 23:47:58 +0900 Subject: [PATCH 39/79] feat(layout): allow groups as connection endpoints (many-to-one to a box) Add the ability to target a GROUP (not just a leaf icon) as a connection's from/to. A line to a group attaches to the group's box edge and is allowed to enter the box (the group's member icons are excluded from that edge's obstacles). This lets a dense many-to-many fan (e.g. 3 compute services each wired to 4 shared resources) be modeled as a clean many-to-ONE to a resource GROUP, which is planar and routes with far fewer crossings. - _find_endpoint / _find_group / _group_member_ids: resolve an endpoint as a node (preferred) or a laid-out group; list a group's leaf members by the flat qualified-id prefix for obstacle exclusion. - _layout_route_connections: resolve endpoints via _find_endpoint; group ports use no label-height offset; group-targeted edges drop the group's members (and box) from their obstacle set; tag edges with _src_group/_dst_group. Verified: three_tier "3 services x 4 resources" goes from 21 crossings (all icon-to-icon) to 0 crossings when the resources are grouped into Data / o11y boxes and each service connects to the box. No regression on icon-to-icon layouts (agent_tree 0/0, ideal_v3 0/1, hier_v1 0/0). --- skill/sdpm/layout/__init__.py | 92 ++++++++++++++++++++++++++++++++--- 1 file changed, 86 insertions(+), 6 deletions(-) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index d860648c..13956625 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -876,8 +876,8 @@ def _layout_route_connections(connections, nodes, groups=None): conn_sides = [] for i, conn in enumerate(connections): - src = _find_node(nodes, conn["from"]) - dst = _find_node(nodes, conn["to"]) + src, _src_is_grp = _find_endpoint(nodes, groups, conn["from"]) + dst, _dst_is_grp = _find_endpoint(nodes, groups, conn["to"]) if not src or not dst: conn_sides.append((None, None, None, None)) continue @@ -1058,6 +1058,12 @@ def _layout_route_connections(connections, nodes, groups=None): edges.append({"from": conn["from"], "to": conn["to"], "label": conn.get("label", ""), "points": []}) continue + # Group endpoints: the port sits on the group's box edge (no label + # offset), and the line is allowed to enter the box — so the group's + # own member icons are excluded from this edge's obstacles. + src_is_grp = _find_node(nodes, conn["from"]) is None + dst_is_grp = _find_node(nodes, conn["to"]) is None + is_fanout_edge = False if i in reverse_set: src_node = _find_node(nodes, conn["from"]) @@ -1068,9 +1074,12 @@ def _layout_route_connections(connections, nodes, groups=None): tp = _port_point(dst_node, "bottom", 0, 1, label_h_dst) points = _detour_path(sp, tp, "bottom", "bottom", global_bottom) else: - label_h = 30 if src.get("label") else 0 - sp = _port_point(src, src_side, port_indices[(i, conn["from"])], port_counts[(conn["from"], src_side)], label_h) - tp = _port_point(dst, dst_side, port_indices[(i, conn["to"])], port_counts[(conn["to"], dst_side)], label_h) + # A group port uses no label height; a node port offsets the bottom + # edge by the label band. + src_label_h = 0 if src_is_grp else (30 if src.get("label") else 0) + dst_label_h = 0 if dst_is_grp else (30 if dst.get("label") else 0) + sp = _port_point(src, src_side, port_indices[(i, conn["from"])], port_counts[(conn["from"], src_side)], src_label_h) + tp = _port_point(dst, dst_side, port_indices[(i, conn["to"])], port_counts[(conn["to"], dst_side)], dst_label_h) # Route every edge with the standard elbow path. The downstream # bend optimizer, side reselection, and detour passes shape each @@ -1078,9 +1087,21 @@ def _layout_route_connections(connections, nodes, groups=None): # no longer special-cased because, when targets sit on a row with # an obstacle between them, a fixed trunk grazes that obstacle and # no trunk position can avoid it (only a detour can). - conn_obs = [o for o in obstacles if o.get("_node") not in (conn["from"], conn["to"])] + excl = {conn["from"], conn["to"]} + # Connecting to/from a group means the line may pass into that + # group's box — exclude the group's member icons (and the group + # box itself) from this edge's obstacles. + if src_is_grp: + excl |= _group_member_ids(nodes, groups, conn["from"]) + if dst_is_grp: + excl |= _group_member_ids(nodes, groups, conn["to"]) + conn_obs = [o for o in obstacles if o.get("_node") not in excl] points = _elbow_path(sp, tp, src_side, dst_side, conn_obs) edge_entry = {"from": conn["from"], "to": conn["to"], "label": conn.get("label", ""), "points": points} + if src_is_grp: + edge_entry["_src_group"] = conn["from"] + if dst_is_grp: + edge_entry["_dst_group"] = conn["to"] edges.append(edge_entry) # T8: Merge fan-out/fan-in groups onto a unified port + shared trunk when @@ -2798,6 +2819,65 @@ def _find_node(nodes, node_id): return None +def _find_group(groups, gid): + """Resolve a group by id (qualified or short), if groups is provided.""" + if not groups: + return None + if gid in groups: + return groups[gid] + for g_id, g in groups.items(): + if g_id.endswith("." + gid): + return g + return None + + +def _find_endpoint(nodes, groups, eid): + """Resolve a connection endpoint that may be a node OR a group. + + Returns (geom, is_group): geom is a dict with x/y/width/height (both nodes + and laid-out groups carry these), is_group flags a group target so callers + can treat the box edge as the port and skip the group's own children as + obstacles. A node takes precedence over a group with the same id. + """ + n = _find_node(nodes, eid) + if n is not None: + return n, False + g = _find_group(groups, eid) + if g is not None: + return g, True + return None, False + + +def _group_qualified_id(groups, gid): + """Return the fully-qualified key of group gid in the flat groups dict.""" + if not groups: + return None + if gid in groups: + return gid + for g_id in groups: + if g_id.endswith("." + gid): + return g_id + return None + + +def _group_member_ids(nodes, groups, gid): + """Short ids of all leaf nodes inside group gid (for obstacle exclusion). + + The collected `groups` dict stores children as qualified id strings, and + every leaf node inside the group is a key in `nodes` prefixed by the + group's qualified id. We match on that prefix. + """ + qid = _group_qualified_id(groups, gid) + if not qid: + return set() + prefix = qid + "." + out = set() + for nid in nodes: + if nid == qid or nid.startswith(prefix): + out.add(nid.rsplit(".", 1)[-1]) + return out + + def _auto_sides(src, dst, group_direction=None): if group_direction == "horizontal": sx = src["x"] + src["width"] // 2 From 2395c50de249be46dc2d1b73c0cf77dafec2ca67 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Mon, 29 Jun 2026 00:07:37 +0900 Subject: [PATCH 40/79] feat(layout): bundle group-endpoint edges into a parallel bus (T14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When several edges share a GROUP endpoint (many-to-one to a box), route them as a tidy parallel bus entering/leaving one box edge through adjacent nested lanes, instead of fanning to scattered points. Lanes are ordered by the opposite end and telescoped (outer edges turn farther out) so the bundle stays crossing-free; the box side is chosen from the bundle's centroid direction. Guarded on the global crossing count (reverts if it would add a crossing). This keeps the user's "merged arrows" look for group fans while preserving the 0-crossing benefit of the many-to-one-to-a-group modeling. Verified on the four complex demos converted to group connections (three_tier, ml_inference, microservices, data_pipeline) — all route at 0 crossings with the bus look. No regression on icon-to-icon layouts. --- skill/sdpm/layout/__init__.py | 99 +++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 13956625..ad674d93 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -1181,10 +1181,109 @@ def _layout_route_connections(connections, nodes, groups=None): # re-introduce a crossing that bend-opt had removed. _recenter_ports(edges, nodes) + # T14: Group bus bundling — when several edges share a GROUP endpoint + # (many-to-one to a box), bundle them so they enter/leave the box edge as a + # tidy parallel bus instead of fanning to scattered points. Nested lanes + # ordered by the opposite end keep the bundle crossing-free. Guarded on the + # global crossing count. + _align_group_bus(edges, nodes, groups) + return edges _PORT_EPS = 4 +_GROUP_BUS_PORT_GAP = 26 +_GROUP_BUS_LANE_GAP = 14 + + +def _align_group_bus(edges, nodes, groups): + """Bundle edges that share a group endpoint into a parallel bus on the box. + + For each group that is the target (or source) of 2+ edges, route those + edges so their final (or first) approach runs as nested parallel lanes into + adjacent ports centered on the box edge facing the other ends. Ordering the + lanes by the opposite end's position keeps the bundle free of self-cross. + Kept only if it does not raise the global crossing count. + """ + if not groups: + return edges + + # Collect bundles: (group_id, role) -> list of edges, where role is 'dst' + # (edges ending at the group) or 'src' (edges starting at the group). + bundles = {} + for e in edges: + if len(e["points"]) < 2: + continue + if e.get("_dst_group"): + bundles.setdefault((e["_dst_group"], "dst"), []).append(e) + if e.get("_src_group"): + bundles.setdefault((e["_src_group"], "src"), []).append(e) + + for (gid, role), grp_edges in bundles.items(): + if len(grp_edges) < 2: + continue + g = _find_group(groups, gid) + if not g: + continue + gx, gy, gw, gh = g["x"], g["y"], g["width"], g["height"] + + # The "free end" of each edge is the non-group end. + def free_pt(e): + return e["points"][0] if role == "dst" else e["points"][-1] + + # Decide which box side faces the bundle: compare the free ends' + # centroid to the box center. + fxs = [free_pt(e)[0] for e in grp_edges] + fys = [free_pt(e)[1] for e in grp_edges] + cfx, cfy = sum(fxs) / len(fxs), sum(fys) / len(fys) + bcx, bcy = gx + gw / 2, gy + gh / 2 + dx, dy = cfx - bcx, cfy - bcy + if abs(dx) >= abs(dy): + side = "left" if dx < 0 else "right" + else: + side = "top" if dy < 0 else "bottom" + vertical_ports = side in ("left", "right") # ports vary along Y + + snapshot = [list(map(list, e["points"])) for e in edges] + before = _count_all_crossings(edges) + + # Order edges by their free end's coordinate along the port axis so + # adjacent ports connect to adjacent sources (no self-cross). + grp_edges.sort(key=lambda e: free_pt(e)[1] if vertical_ports else free_pt(e)[0]) + n = len(grp_edges) + # Box-edge anchor coordinates (the fixed coordinate of the port line). + bx = gx if side == "left" else (gx + gw) # used when vertical_ports + by = gy if side == "top" else (gy + gh) # used otherwise + + for rank, e in enumerate(grp_edges): + off = (rank - (n - 1) / 2) * _GROUP_BUS_PORT_GAP + fp = free_pt(e) + # Nested lane: outer (farther from center) edges turn earlier so the + # bundle telescopes without crossing. + lane_depth = (n - rank) * _GROUP_BUS_LANE_GAP if role == "dst" else (rank + 1) * _GROUP_BUS_LANE_GAP + if vertical_ports: + py = round(bcy + off) + # Outer lanes turn farther from the box so the bundle telescopes. + lane = (gx - 20 - lane_depth) if side == "left" else (gx + gw + 20 + lane_depth) + port = [bx, py] + if role == "dst": + e["points"] = [fp, [lane, fp[1]], [lane, py], port] + else: + e["points"] = [port, [lane, py], [lane, fp[1]], fp] + else: + px = round(bcx + off) + lane = (gy - 20 - lane_depth) if side == "top" else (gy + gh + 20 + lane_depth) + port = [px, by] + if role == "dst": + e["points"] = [fp, [fp[0], lane], [px, lane], port] + else: + e["points"] = [port, [px, lane], [fp[0], lane], fp] + + if _count_all_crossings(edges) > before: + for e, pts in zip(edges, snapshot): + e["points"] = pts + + return edges def _recenter_ports(edges, nodes): From df9c2d84f2c5e1aa7beb15099aa214717cc075e9 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Mon, 29 Jun 2026 01:05:36 +0900 Subject: [PATCH 41/79] fix(layout): group hubs in fan-merge + roll back fans that pierce/cross Two fixes so "fan":"merge" produces a real unified trunk without the broken ports/pierces seen on dense diagrams: - _rewrite_fan now resolves the hub (and each spoke) via _find_endpoint, so a GROUP can be the merge hub: the shared port lands on the group's box edge with no label offset, exactly like a node. Previously _find_node returned None for a group hub and the merge fell back to averaging stale ports, so group fan-ins never actually converged to one point. - _align_fan_bends applies each fan bundle under a guard: if the merge makes the bundle's own edges pierce icons (e.g. sources stacked in line with the hub, where forcing one trunk drives an upper source's stub through a lower one) or raises global crossings, it rolls the bundle back to individual routing and drops _fan_locked. Merge is kept only where the spokes spread perpendicular to the hub and it actually helps. - T14 group-bus bundling skips _fan_locked edges so it doesn't undo a merge. Effect: fan_hpair 12->0 crossings (bad merges now self-disable); the four complex demos modeled as group many-to-one all route at 0 crossings with the fan bundles that help kept and the harmful ones reverted. No regression on icon-to-icon layouts. --- skill/sdpm/layout/__init__.py | 56 +++++++++++++++++++++++++---------- 1 file changed, 40 insertions(+), 16 deletions(-) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index ad674d93..6bef67ae 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -1110,7 +1110,7 @@ def _layout_route_connections(connections, nodes, groups=None): # trunks untouched. Crossing avoidance for merged fans comes from placement # (the order/direction search) and from routing the OTHER edges around the # fixed trunks — not from un-merging them. - _align_fan_bends(edges, conn_sides, connections, nodes) + _align_fan_bends(edges, conn_sides, connections, nodes, groups) # T9: Safe bend separation — shift overlapping vertical bends apart # while preserving axis-alignment (only move X of vertical segments, @@ -1214,6 +1214,10 @@ def _align_group_bus(edges, nodes, groups): for e in edges: if len(e["points"]) < 2: continue + # A fan-merged group edge is already a deliberate single-trunk bundle; + # leave it to the fan layout, don't re-bundle it here. + if e.get("_fan_locked"): + continue if e.get("_dst_group"): bundles.setdefault((e["_dst_group"], "dst"), []).append(e) if e.get("_src_group"): @@ -1624,7 +1628,7 @@ def _heuristic_port_sort(conn_indices, nid, side, port_indices, conn_sides, conn _FAN_SPREAD_LIMIT = 600 -def _align_fan_bends(edges, conn_sides, connections, nodes=None): +def _align_fan_bends(edges, conn_sides, connections, nodes=None, groups=None): """Align bend positions and merge ports for fan-out and fan-in groups. Only activates when connections have "fan": "merge" set. A merged group is @@ -1638,6 +1642,23 @@ def _align_fan_bends(edges, conn_sides, connections, nodes=None): optimizers (bend slide, side reselect, detour) leave its trunk alone — the merge is the spec, and crossing reduction must work AROUND it, not undo it. """ + def _apply_fan_guarded(indices, mode): + # Apply the merge, but ROLL BACK if it makes the bundle's own edges + # pierce icons (e.g. sources stacked in line with the hub — forcing them + # onto one trunk drives the upper source's stub through the lower one) + # or raises total crossings. The merge is desirable only when the spokes + # are spread perpendicular to the hub; otherwise individual routing wins. + snap = {j: list(map(list, edges[j]["points"])) for j in indices} + before_p = _count_node_pierces([edges[j] for j in indices], nodes) + before_c = _count_all_crossings(edges) + _rewrite_fan(edges, conn_sides, indices, mode=mode, nodes=nodes, groups=groups) + after_p = _count_node_pierces([edges[j] for j in indices], nodes) + after_c = _count_all_crossings(edges) + if after_p > before_p or after_c > before_c: + for j in indices: + edges[j]["points"] = snap[j] + edges[j].pop("_fan_locked", None) + # Fan-out: group purely by source node (side decided later by vote). src_groups = {} for i, (src, dst, src_side, dst_side) in enumerate(conn_sides): @@ -1650,7 +1671,7 @@ def _align_fan_bends(edges, conn_sides, connections, nodes=None): for indices in src_groups.values(): if len(indices) < 2: continue - _rewrite_fan(edges, conn_sides, indices, mode="fan_out", nodes=nodes) + _apply_fan_guarded(indices, "fan_out") # Fan-in: group purely by target node. dst_groups = {} @@ -1664,7 +1685,7 @@ def _align_fan_bends(edges, conn_sides, connections, nodes=None): for indices in dst_groups.values(): if len(indices) < 2: continue - _rewrite_fan(edges, conn_sides, indices, mode="fan_in", nodes=nodes) + _apply_fan_guarded(indices, "fan_in") _MAX_RESOLVE_ITERATIONS = 30 @@ -2775,7 +2796,7 @@ def _fan_side_vote(edges, conn_sides, indices, mode): return sorted(votes.items(), key=lambda kv: (-kv[1], pref.get(kv[0], 9)))[0][0] -def _rewrite_fan(edges, conn_sides, indices, mode, nodes=None): +def _rewrite_fan(edges, conn_sides, indices, mode, nodes=None, groups=None): """Force a fan-out/fan-in group onto a unified port and a shared trunk. The merge is a hard constraint (the LLM asked for it), so we rebuild every @@ -2783,23 +2804,25 @@ def _rewrite_fan(edges, conn_sides, indices, mode, nodes=None): - one shared port on the hub node (computed from node geometry, centered), - a shared trunk coordinate (all edges bend at the same line), - the spoke then peels off to each individual target/source. - Edges that had become detours (len>4) are rebuilt too. Each edge is tagged - `_fan_locked` with the trunk axis/value so downstream optimizers don't undo - the alignment. + The hub may be a NODE or a GROUP (box) — both expose x/y/width/height, so a + group hub gets a single shared port on its box edge just like a node. Edges + that had become detours (len>4) are rebuilt too. Each edge is tagged + `_fan_locked` so downstream optimizers don't undo the alignment. """ if not indices: return side = _fan_side_vote(edges, conn_sides, indices, mode) vertical = side in ("top", "bottom") - # Resolve the hub node (shared end) and its geometry for an exact port. + # Resolve the hub (shared end) — node OR group — and its geometry. hub_id = edges[indices[0]]["from"] if mode == "fan_out" else edges[indices[0]]["to"] - hub = _find_node(nodes, hub_id) if nodes else None + hub, hub_is_group = _find_endpoint(nodes or {}, groups or {}, hub_id) # Unified port point on the hub edge, centered along that edge. if hub is not None: hx, hy, hw, hh = hub["x"], hub["y"], hub["width"], hub["height"] - label_h = 30 if hub.get("label") else 0 + # A group port sits on the box edge (no label band offset). + label_h = 0 if hub_is_group else (30 if hub.get("label") else 0) if side == "right": port = [hx + hw, hy + hh // 2] elif side == "left": @@ -2843,9 +2866,9 @@ def _rewrite_fan(edges, conn_sides, indices, mode, nodes=None): # router first picked, which left planner exiting "right" and coder "left"). # The spoke side is the side facing the trunk: opposite the hub side for the # spoke's own port normal. - def spoke_port(node, sside): + def spoke_port(node, sside, is_group): nx, ny, nw, nh = node["x"], node["y"], node["width"], node["height"] - nlabel_h = 30 if node.get("label") else 0 + nlabel_h = 0 if is_group else (30 if node.get("label") else 0) if sside == "bottom": return [nx + nw // 2, ny + nh + nlabel_h] if sside == "top": @@ -2855,9 +2878,10 @@ def spoke_port(node, sside): return [nx, ny + nh // 2] # left for i in indices: - # spoke node = the per-edge individual end (target for fan-out, source for fan-in) + # spoke end = the per-edge individual end (target for fan-out, source for + # fan-in); it may itself be a node OR a group. spoke_id = edges[i]["to"] if mode == "fan_out" else edges[i]["from"] - spoke_node = _find_node(nodes, spoke_id) if nodes else None + spoke_node, spoke_is_group = _find_endpoint(nodes or {}, groups or {}, spoke_id) pts = edges[i]["points"] # Decide which spoke edge faces the trunk. The trunk is a line on the @@ -2872,7 +2896,7 @@ def spoke_port(node, sside): s_side = "right" if trunk_v >= ref else "left" if spoke_node is not None: - spoke_pt = spoke_port(spoke_node, s_side) + spoke_pt = spoke_port(spoke_node, s_side, spoke_is_group) else: spoke_pt = list(pts[-1] if mode == "fan_out" else pts[0]) From 9f79b45f206e470a2969e3d432e0fb604c4b538c Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Mon, 29 Jun 2026 01:20:51 +0900 Subject: [PATCH 42/79] fix(layout): decide fan hub side from geometry; keep trunk inside the gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes so fan-merge picks sane ports for fan-outs/fan-ins whose hub sits squarely above/below/beside its spokes: - _fan_side_vote now chooses the hub's shared side from GEOMETRY — the box edge facing the spokes' centroid — instead of a majority vote over the router's per-edge sides. A hub directly below a row of sources was getting "right" (each spoke saw a different diagonal), so the merge attached on the wrong edge and self-disabled. Now it correctly returns "top" and the row fans in from below. - _rewrite_fan trunk coordinate is clamped strictly between the hub port and the nearest spoke: when the gap is narrower than the bend margin it sits at the midpoint instead of overshooting into the spoke icons (which drove stacked fan-out trunks through their targets). Effect: Step Functions -> 3 Lambdas now merges from the bottom into a clean 3-way trunk (previously reverted). No regression (agent_tree 0/0, ideal_v3 0/1, hier_v1 0/0, fan_hpair 0/0). --- skill/sdpm/layout/__init__.py | 72 +++++++++++++++++++++++------------ 1 file changed, 48 insertions(+), 24 deletions(-) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 6bef67ae..73ca03d2 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -2776,14 +2776,35 @@ def _update_bend_x(points, old_x, new_x): _FAN_BEND_MARGIN = 30 -def _fan_side_vote(edges, conn_sides, indices, mode): - """Pick the single shared side for a fan group by majority vote. - - The hub end (src for fan-out, dst for fan-in) must agree on ONE side so - all edges leave/enter through one unified port. The router may have chosen - different sides per edge; we take the most common, tie-broken by a stable - preference order. +def _fan_side_vote(edges, conn_sides, indices, mode, nodes=None, groups=None): + """Pick the single shared hub side for a fan group from GEOMETRY. + + The hub end (src for fan-out, dst for fan-in) must agree on ONE side so all + edges leave/enter through one unified port. We choose the box edge that + faces the spokes' centroid: e.g. a hub directly BELOW a row of spokes is + entered through its TOP. This is far more robust than the old majority vote + over per-edge router sides, which picked "right" for a hub sitting squarely + below its sources (each spoke saw a different diagonal direction). """ + hub_id = edges[indices[0]]["from"] if mode == "fan_out" else edges[indices[0]]["to"] + hub, _ = _find_endpoint(nodes or {}, groups or {}, hub_id) + spokes = [] + for i in indices: + sid = edges[i]["to"] if mode == "fan_out" else edges[i]["from"] + s, _ = _find_endpoint(nodes or {}, groups or {}, sid) + if s is not None: + spokes.append(s) + if hub is not None and spokes: + hcx = hub["x"] + hub["width"] / 2 + hcy = hub["y"] + hub["height"] / 2 + scx = sum(s["x"] + s["width"] / 2 for s in spokes) / len(spokes) + scy = sum(s["y"] + s["height"] / 2 for s in spokes) / len(spokes) + dx, dy = scx - hcx, scy - hcy # direction from hub toward spokes + if abs(dx) >= abs(dy): + return "right" if dx > 0 else "left" + return "bottom" if dy > 0 else "top" + + # Fallback: majority vote over router-chosen sides. pref = {"right": 0, "left": 1, "bottom": 2, "top": 3} votes = {} for i in indices: @@ -2811,7 +2832,7 @@ def _rewrite_fan(edges, conn_sides, indices, mode, nodes=None, groups=None): """ if not indices: return - side = _fan_side_vote(edges, conn_sides, indices, mode) + side = _fan_side_vote(edges, conn_sides, indices, mode, nodes, groups) vertical = side in ("top", "bottom") # Resolve the hub (shared end) — node OR group — and its geometry. @@ -2837,28 +2858,31 @@ def _rewrite_fan(edges, conn_sides, indices, mode, nodes=None, groups=None): for j in indices] port = [sum(p[0] for p in ends) // len(ends), sum(p[1] for p in ends) // len(ends)] - # Shared trunk coordinate: a line just outside the hub port, on the spoke - # side, where all edges turn toward their individual targets. + # Shared trunk coordinate: a line in the GAP between the hub port and the + # nearest spoke. It must stay strictly between the two — if the gap is + # narrower than the preferred margin, fall back to the midpoint rather than + # overshooting the spoke (which would drive the trunk into the spoke icons, + # the bug that made stacked fan-outs pierce their targets). spoke_ends = [edges[j]["points"][-1] if mode == "fan_out" else edges[j]["points"][0] for j in indices] + + def _gap_trunk(p0, nearest): + # p0 = hub port coordinate, nearest = closest spoke coordinate. + lo, hi = (p0, nearest) if p0 <= nearest else (nearest, p0) + mid = (p0 + nearest) // 2 + if hi - lo <= 2 * _FAN_BEND_MARGIN: + return mid # gap too tight for the margin → sit in the middle + # otherwise sit _FAN_BEND_MARGIN away from the hub, toward the spoke + return p0 + _FAN_BEND_MARGIN if p0 < nearest else p0 - _FAN_BEND_MARGIN + if vertical: - # trunk is a horizontal line at y = trunk_v; pick it between the hub - # port and the nearest spoke end so the trunk sits in the gap. spoke_vs = [p[1] for p in spoke_ends] - if side == "bottom": - nearest = min(spoke_vs) - trunk_v = max(port[1] + _FAN_BEND_MARGIN, (port[1] + nearest) // 2) - else: # top - nearest = max(spoke_vs) - trunk_v = min(port[1] - _FAN_BEND_MARGIN, (port[1] + nearest) // 2) + nearest = min(spoke_vs) if side == "bottom" else max(spoke_vs) + trunk_v = _gap_trunk(port[1], nearest) else: spoke_hs = [p[0] for p in spoke_ends] - if side == "right": - nearest = min(spoke_hs) - trunk_v = max(port[0] + _FAN_BEND_MARGIN, (port[0] + nearest) // 2) - else: # left - nearest = max(spoke_hs) - trunk_v = min(port[0] - _FAN_BEND_MARGIN, (port[0] + nearest) // 2) + nearest = min(spoke_hs) if side == "right" else max(spoke_hs) + trunk_v = _gap_trunk(port[0], nearest) # The spoke nodes (the N individual ends) must ALSO leave/enter through a # consistent edge — the one facing the trunk. A fan-in to a trunk BELOW the From 9e089fde818d0288754e4bfb649db9151e4f9ae5 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Mon, 29 Jun 2026 02:32:44 +0900 Subject: [PATCH 43/79] fix(layout): don't count a fan bundle's shared trunk as crossings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _count_all_crossings was counting the COLLINEAR OVERLAP of two edges that share an endpoint (a fan-out from one source / fan-in to one target) as a crossing. That overlap is exactly the merged bundle's shared trunk — the intended result of "fan":"merge", not a defect. So a clean N-way merge was scored as N-choose-2 phantom crossings, which then made the fan guard roll the merge back and left the arrows un-merged. Now: for an edge pair that shares an endpoint node, a collinear overlap on their common trunk is ignored; only a genuine perpendicular crossing counts. Unrelated edges still count overlaps (two separate arrows on one line is a real defect). Effect: with all bundles fan-merged, g_dp 9->0, tt_group 10->1, g_ml 1->0 crossings; SF->Lambda, Lambda->S3, ALB->containers all merge cleanly. Also fixes phantom crossings on non-fan layouts that happened to share trunks (ideal_v3 1->0). No regression (agent_tree 0/0, hier_v1 0/0, fan_hpair 0/0). --- skill/sdpm/layout/__init__.py | 52 ++++++++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 73ca03d2..eeed248b 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -1643,18 +1643,21 @@ def _align_fan_bends(edges, conn_sides, connections, nodes=None, groups=None): merge is the spec, and crossing reduction must work AROUND it, not undo it. """ def _apply_fan_guarded(indices, mode): - # Apply the merge, but ROLL BACK if it makes the bundle's own edges - # pierce icons (e.g. sources stacked in line with the hub — forcing them - # onto one trunk drives the upper source's stub through the lower one) - # or raises total crossings. The merge is desirable only when the spokes - # are spread perpendicular to the hub; otherwise individual routing wins. + # The user wants same-purpose edges merged, so a merge that adds only a + # MODEST number of crossings is kept (a tidy trunk reads better than a + # few crossings). Roll back when: + # - the bundle's own edges PIERCE icons (genuinely broken, e.g. sources + # stacked in line with the hub), or + # - the merge adds MORE crossings than the bundle size — a sign the + # trunk is fighting another structure (e.g. a hub that is both a + # fan-in and fan-out target), where separate routing is cleaner. snap = {j: list(map(list, edges[j]["points"])) for j in indices} before_p = _count_node_pierces([edges[j] for j in indices], nodes) before_c = _count_all_crossings(edges) _rewrite_fan(edges, conn_sides, indices, mode=mode, nodes=nodes, groups=groups) after_p = _count_node_pierces([edges[j] for j in indices], nodes) after_c = _count_all_crossings(edges) - if after_p > before_p or after_c > before_c: + if after_p > before_p or (after_c - before_c) > len(indices): for j in indices: edges[j]["points"] = snap[j] edges[j].pop("_fan_locked", None) @@ -1729,20 +1732,55 @@ def _find_first_crossing(edges): return None +def _segments_overlap_collinear(a1, a2, b1, b2): + """True if the two axis-aligned segments lie on the same line and overlap + (as opposed to crossing perpendicularly).""" + if a1[1] == a2[1] and b1[1] == b2[1] and a1[1] == b1[1]: # both horizontal, same Y + a_min, a_max = min(a1[0], a2[0]), max(a1[0], a2[0]) + b_min, b_max = min(b1[0], b2[0]), max(b1[0], b2[0]) + return min(a_max, b_max) - max(a_min, b_min) > 5 + if a1[0] == a2[0] and b1[0] == b2[0] and a1[0] == b1[0]: # both vertical, same X + a_min, a_max = min(a1[1], a2[1]), max(a1[1], a2[1]) + b_min, b_max = min(b1[1], b2[1]), max(b1[1], b2[1]) + return min(a_max, b_max) - max(a_min, b_min) > 5 + return False + + def _count_all_crossings(edges): - """Count total crossing pairs across all edges.""" + """Count crossing pairs across all edges. + + Two edges that SHARE an endpoint node (a fan-out from the same source or a + fan-in to the same target) are allowed to run on top of each other on their + shared trunk — that overlap IS the merged bundle, not a crossing. So for + such pairs we ignore collinear overlaps and only count a genuine + perpendicular crossing. Unrelated edges still count overlaps (two separate + arrows drawn on the same line read as a defect).""" count = 0 for i in range(len(edges)): pts_i = edges[i]["points"] if len(pts_i) < 2: continue + ei = edges[i] for j in range(i + 1, len(edges)): pts_j = edges[j]["points"] if len(pts_j) < 2: continue + ej = edges[j] + shares_endpoint = ( + ei.get("from") == ej.get("from") + or ei.get("to") == ej.get("to") + or ei.get("from") == ej.get("to") + or ei.get("to") == ej.get("from") + ) for si in range(len(pts_i) - 1): for sj in range(len(pts_j) - 1): if _segments_intersect(pts_i[si], pts_i[si + 1], pts_j[sj], pts_j[sj + 1]): + # A shared-endpoint bundle's collinear overlap is the + # intended trunk, not a crossing. + if shares_endpoint and _segments_overlap_collinear( + pts_i[si], pts_i[si + 1], pts_j[sj], pts_j[sj + 1] + ): + continue count += 1 return count From 8e1045f8dcf891990a6297796bfd0b23c0534fa0 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Mon, 29 Jun 2026 10:53:05 +0900 Subject: [PATCH 44/79] feat(layout): promote degree-1 branch nodes to perpendicular lane Shape-first pre-pass: detect a degree-1 leaf wedged on the main flow line whose anchor has a through-edge crossing its slot (e.g. a Bedrock fallback between Router and the model group). Drawn linearly the through-edge pierces it; wrap {anchor, branch} into an invisible sub-group oriented perpendicular to the parent so the main line stays straight and the auxiliary node is offset to the side. cx_ml_inference: router->nlp no longer pierces bedrock (pierces 1->0, score 4.5->3.0). Non-branch hubs (design_B bedrock, degree-4) and already-vertical aux nodes (three_tier cognito) correctly untouched. --- skill/sdpm/layout/__init__.py | 100 ++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index eeed248b..a4c41693 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -16,9 +16,109 @@ def optimize_order(tree): connections = tree.get("connections", []) if not connections: return + # Shape-first pre-pass: pull degree-1 auxiliary nodes that sit ON the main + # flow line out into a perpendicular lane so the straight through-edge does + # not pierce them (e.g. a Bedrock fallback wedged between Router and the + # model group). Runs before ordering so the new sub-groups get ordered too. + _promote_branch_nodes(tree, connections) _optimize_group_order(tree, connections) +def _promote_branch_nodes(node, connections, root=None): + """Move degree-1 'branch' leaves off the main flow into a perpendicular lane. + + Detects the human-layout pattern: in a linear (horizontal/vertical) group, + a leaf ``b`` with exactly one connection whose anchor ``a`` is a sibling in + the same group, where another edge from ``a`` runs straight past ``b``'s + slot to a node on the far side. Drawn linearly, that through-edge pierces + ``b``. The fix (Shape-First / bend-minimisation philosophy: keep the main + line straight, displace the auxiliary node) wraps ``{a, b}`` into an + invisible sub-group oriented PERPENDICULAR to the parent, so ``a`` stays on + the flow line and ``b`` is offset to the side. Mutates ``node`` in place. + """ + if root is None: + root = node + children = node.get("children", []) + # Depth-first so inner groups are handled before we look at this level. + for child in children: + _promote_branch_nodes(child, connections, root) + children = node.get("children", []) + direction = node.get("direction") + if len(children) < 3 or direction not in ("horizontal", "vertical"): + return + + # Map each direct child -> the leaf ids it contains; and each leaf -> the + # index of the direct child that owns it (a sub-group counts as one slot). + leaf_to_idx = {} + for i, c in enumerate(children): + ids = [] + _collect_leaf_ids(c, ids) + for lid in ids: + leaf_to_idx[lid] = i + + # Global degree + neighbour list over all connections. + deg = {} + nbr = {} + for conn in connections: + for a, b in ((conn["from"], conn["to"]), (conn["to"], conn["from"])): + deg[a] = deg.get(a, 0) + 1 + nbr.setdefault(a, []).append(b) + + # Collect qualifying branch leaves, grouped by their anchor. + branches_by_anchor = {} + for i, c in enumerate(children): + if c.get("children"): + continue # only bare leaves can be branch nodes + bid = c.get("id") + if bid is None or deg.get(bid, 0) != 1: + continue + aid = nbr[bid][0] + ia = leaf_to_idx.get(aid) + if ia is None or children[ia].get("children"): + continue # anchor must be a direct-child leaf of this same group + # Is there a through-edge from the anchor that crosses b's slot? + ib = i + through = False + for other in nbr.get(aid, []): + if other == bid: + continue + ic = leaf_to_idx.get(other) + if ic is not None and (ia - ib) * (ic - ib) < 0: + through = True + break + if through: + branches_by_anchor.setdefault(aid, (ia, []))[1].append((ib, c)) + + if not branches_by_anchor: + return + + perp = "vertical" if direction == "horizontal" else "horizontal" + remove = set() + inserts = {} # insertion index -> new sub-group node + for aid, (ia, brs) in branches_by_anchor.items(): + anchor = children[ia] + slot = min([ia] + [ib for ib, _ in brs]) + # anchor first so it keeps the centred slot on the flow line; branches + # follow in their original order. + members = [anchor] + [c for _, c in sorted(brs)] + inserts[slot] = { + "id": "_branchlane_" + (aid or "x"), + "direction": perp, + "children": members, + } + remove.add(ia) + remove.update(ib for ib, _ in brs) + + rebuilt = [] + for i, c in enumerate(children): + if i in inserts: + rebuilt.append(inserts[i]) + if i in remove: + continue + rebuilt.append(c) + node["children"] = rebuilt + + def _optimize_group_order(node, connections, root=None): """Recursively optimize child order within groups (leaf-only AND mixed).""" if root is None: From 26d3af841805a2bb91566ca150cedca37430a45d Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Mon, 29 Jun 2026 11:32:55 +0900 Subject: [PATCH 45/79] fix(layout): keep branch-lane anchor on the main flow line Block-placement centred the promoted {anchor, branch} lane by its bounding box, pushing the anchor off the flow axis (router cy 408->321, breaking the apigw->router straight line). Tag the lane with its anchor id and add _align_branch_lane_anchors: after the sibling-center pass, translate each lane so the ANCHOR center returns to the flow line and the branch hangs off to the side. cx_ml_inference: client/apigw/router/nlp now share cy=408; bedrock hangs below. pierces still 0, wirelength 5401->5227. Lane-free cases unaffected. --- skill/sdpm/layout/__init__.py | 36 +++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index a4c41693..d3c5e37d 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -105,6 +105,10 @@ def _promote_branch_nodes(node, connections, root=None): "id": "_branchlane_" + (aid or "x"), "direction": perp, "children": members, + # Remember which member is the flow anchor so the layout pass can + # keep IT (not the lane's centroid) on the main flow line, letting + # the branch hang off to the side. + "_branch_anchor": aid, } remove.add(ia) remove.update(ib for ib, _ in brs) @@ -829,6 +833,7 @@ def _align_leaves_to_sibling_centers(ordered): dy = target_cy - current_cy if dy != 0: _layout_translate(child, 0, dy) + _align_branch_lane_anchors(ordered, target_cy, axis="y") def _align_leaves_to_sibling_centers_h(ordered): @@ -868,6 +873,37 @@ def _align_leaves_to_sibling_centers_h(ordered): dx = target_cx - current_cx if dx != 0: _layout_translate(child, dx, 0) + _align_branch_lane_anchors(ordered, target_cx, axis="x") + + +def _align_branch_lane_anchors(ordered, target, axis): + """Shift each promoted branch lane so its ANCHOR (not the lane centroid) + sits on the main flow line. + + A branch lane (created by _promote_branch_nodes) stacks {anchor, branch} + perpendicular to the flow. Block-placement centres the lane's bounding box, + which pushes the anchor off the flow axis. We translate the whole lane so + the anchor's center returns to ``target`` and the branch hangs off to the + side, keeping the through-flow edge straight. axis "y" → vertical flow + offset (horizontal parent); axis "x" → horizontal offset (vertical parent). + """ + pos_idx = 1 if axis == "y" else 0 + size_idx = 3 if axis == "y" else 2 + for child in ordered: + aid = child.get("_branch_anchor") + if not aid or not child.get("children"): + continue + anchor = next((m for m in child["children"] if m.get("id") == aid), None) + if anchor is None: + continue + ab = anchor["_bindings"] + anchor_center = ab[pos_idx] + ab[size_idx] // 2 + delta = target - anchor_center + if delta != 0: + if axis == "y": + _layout_translate(child, 0, delta) + else: + _layout_translate(child, delta, 0) def _layout_collect(node, nodes_out, groups_out, prefix=""): From 87a35db37a9d618a0d1bf5008e5f9cf970fd6fac Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Mon, 29 Jun 2026 13:50:24 +0900 Subject: [PATCH 46/79] feat(layout): fan-merge pierce guard + manual branch-lane anchoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two refinements to the fan/branch handling: 1. Fan-merge guard now rolls back when the merged trunk pierces ANY icon (after_p > 0), not just when the merge ADDED pierces (after_p > before_p). A fan bundle is _fan_locked, so downstream pierce-resolution passes (reselect/detour) cannot clear it later — whatever a locked trunk cuts through is permanent. The old delta test compared two pre-optimization snapshots and wrongly kept a doomed trunk (four agents fanning into a Bedrock hub through the icons below them). Verified: all genuinely-clean bundles (event_bridge->agents, stepfn->lambdas, ECS->o11y) reach after_p=0 and survive; only the doomed ones roll back. hier_v1 + fan: 0c/3p -> 0c/0p. microservices: weighted 15 -> 11.5. 2. _tag_manual_branch_anchors: hand-authored invisible linear groups (e.g. a {router, bedrock} column the LLM writes directly) now get the same _branch_anchor tag the engine assigns to lanes it promotes itself. The anchor is inferred as the sole member connecting outside the group. The layout pass then keeps that member on the flow line and hangs the rest off to the side, so API GW -> Router stays straight while Bedrock drops below. Side effect: three_tier {cognito, alb} aligns too, 12c -> 9c. --- skill/sdpm/layout/__init__.py | 73 +++++++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 4 deletions(-) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index d3c5e37d..64194df1 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -21,9 +21,67 @@ def optimize_order(tree): # not pierce them (e.g. a Bedrock fallback wedged between Router and the # model group). Runs before ordering so the new sub-groups get ordered too. _promote_branch_nodes(tree, connections) + # Also tag hand-authored invisible lanes (e.g. a {router, bedrock} vertical + # column the LLM wrote directly) with their flow anchor, so the same + # anchor-on-flow alignment applies as for engine-promoted lanes. + _tag_manual_branch_anchors(tree, connections) _optimize_group_order(tree, connections) +def _tag_manual_branch_anchors(node, connections, root=None): + """Tag hand-authored invisible linear sub-groups with their flow anchor. + + The engine's own _promote_branch_nodes tags the lanes IT creates, but an + author may hand-write the same shape — an invisible (no groupType/label) + horizontal/vertical group stacking a flow node with an auxiliary one, e.g. + ``{router, bedrock}`` so Bedrock sits beside Router. Block-placement then + centres that group by its bounding box, pushing the flow node off the main + line. We detect such a group and tag it with ``_branch_anchor`` = the sole + member that connects OUTSIDE the group (the flow node); the layout pass then + keeps that member on the flow line and lets the rest hang off to the side. + Mutates in place. Skips groups that already carry the tag. + """ + if root is None: + root = node + for child in node.get("children", []): + _tag_manual_branch_anchors(child, connections, root) + + children = node.get("children", []) + if len(children) < 2 or node.get("_branch_anchor"): + return + # Only invisible linear groups qualify (a visible box is a deliberate + # cluster, not a flow node + offset branch). + if node.get("direction") not in ("horizontal", "vertical"): + return + if node.get("groupType") or node.get("label"): + return + # Every direct child must be a bare leaf (the {anchor, branch} pattern). + member_ids = [] + for c in children: + if c.get("children") or "id" not in c: + return + member_ids.append(c["id"]) + member_set = set(member_ids) + + # An "anchor" is a member that connects to something OUTSIDE this group. + outward = [] + for c in children: + cid = c["id"] + for conn in connections: + other = None + if conn["from"] == cid: + other = conn["to"] + elif conn["to"] == cid: + other = conn["from"] + if other is not None and other not in member_set: + outward.append(cid) + break + # Tag only when exactly ONE member reaches outside — that is unambiguously + # the flow node; the rest are branches hanging off it. + if len(outward) == 1: + node["_branch_anchor"] = outward[0] + + def _promote_branch_nodes(node, connections, root=None): """Move degree-1 'branch' leaves off the main flow into a perpendicular lane. @@ -1782,18 +1840,25 @@ def _apply_fan_guarded(indices, mode): # The user wants same-purpose edges merged, so a merge that adds only a # MODEST number of crossings is kept (a tidy trunk reads better than a # few crossings). Roll back when: - # - the bundle's own edges PIERCE icons (genuinely broken, e.g. sources - # stacked in line with the hub), or + # - the merged trunk PIERCES any icon. A fan bundle is `_fan_locked`, + # so the downstream pierce-resolution passes (side reselect, detour) + # CANNOT clear it later — whatever the locked trunk cuts through is + # permanent. Individually-routed edges, by contrast, get cleaned up + # by those passes, so an unmerged fan whose members pierce here may + # still reach 0 pierces in the final layout. Hence the test is + # "trunk pierces anything at all" (after_p > 0), not the weaker + # "merge ADDED pierces" — the latter compares two pre-optimization + # snapshots and wrongly keeps a doomed locked trunk (e.g. four + # agents fanning into a Bedrock hub through the icons below them). # - the merge adds MORE crossings than the bundle size — a sign the # trunk is fighting another structure (e.g. a hub that is both a # fan-in and fan-out target), where separate routing is cleaner. snap = {j: list(map(list, edges[j]["points"])) for j in indices} - before_p = _count_node_pierces([edges[j] for j in indices], nodes) before_c = _count_all_crossings(edges) _rewrite_fan(edges, conn_sides, indices, mode=mode, nodes=nodes, groups=groups) after_p = _count_node_pierces([edges[j] for j in indices], nodes) after_c = _count_all_crossings(edges) - if after_p > before_p or (after_c - before_c) > len(indices): + if after_p > 0 or (after_c - before_c) > len(indices): for j in indices: edges[j]["points"] = snap[j] edges[j].pop("_fan_locked", None) From 765b8e376ad82d1eda9ae47db2c9386f80740516 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Mon, 29 Jun 2026 13:54:01 +0900 Subject: [PATCH 47/79] docs(guides): add nested-JSON layout engine guide for LLMs New guide arch-layout-engine.md documents the auto-routing layout engine (logical-structure JSON -> placed elements + routed arrows) and the three techniques that take dense AWS diagrams to 0 crossings/pierces: 1. connect to a GROUP box (many-to-many -> many-to-one) 2. fan:merge bundles (self-protecting: rolls back if the trunk would pierce an icon) 3. degree-1 branch nodes wrapped in an invisible perpendicular group so the main flow line stays straight Also documents group-frame visibility (omit groupType -> no frame), the multi-objective judge, the layout CLI/QA commands, anti-patterns, and four worked examples. Auto-discovered by list_guides. Cross-linked from arch-elements.md, which keeps the shared vocabulary (group colors, boundary/logo, box nodes, manual arrow geometry). --- skill/references/guides/arch-elements.md | 7 + skill/references/guides/arch-layout-engine.md | 261 ++++++++++++++++++ 2 files changed, 268 insertions(+) create mode 100644 skill/references/guides/arch-layout-engine.md diff --git a/skill/references/guides/arch-elements.md b/skill/references/guides/arch-elements.md index 94be9242..68b123fb 100644 --- a/skill/references/guides/arch-elements.md +++ b/skill/references/guides/arch-elements.md @@ -6,6 +6,13 @@ Common components for architecture diagrams (borders, groups, arrows, scale, col Shared components and rules used across architecture diagram patterns (simple / complex). +> **Building a diagram from connections (what-links-to-what)?** Prefer the +> nested-JSON layout engine — it auto-places icons and auto-routes orthogonal +> arrows, minimizing crossings/pierces. See +> [arch-layout-engine.md](arch-layout-engine.md). This file (arch-elements.md) +> covers the shared vocabulary — group colors/types, boundary & logo, box +> nodes, manual arrow geometry — used by both the engine and hand-placement. + --- ## Core Principles diff --git a/skill/references/guides/arch-layout-engine.md b/skill/references/guides/arch-layout-engine.md new file mode 100644 index 00000000..a97e84a8 --- /dev/null +++ b/skill/references/guides/arch-layout-engine.md @@ -0,0 +1,261 @@ +--- +description: "Nested-JSON architecture layout engine (auto-routing) — read when building an AWS/system architecture diagram from logical structure instead of hand-placing coordinates" +--- + +# Architecture Layout Engine (nested-JSON auto-router) + +The layout engine turns a **logical structure** (groups, icons, connections) into +fully-placed elements with auto-routed orthogonal arrows. You describe *what +connects to what*; the engine computes coordinates, clusters related icons, +chooses arrow ports/bends, and minimizes crossings and icon pierces. + +Prefer this over hand-placing coordinates (the manual style in +[arch-elements.md](arch-elements.md)) for any diagram with more than a few +connections. Hand-placement is for fine-tuning or non-flow art. + +> Companion: [arch-elements.md](arch-elements.md) covers group colors, +> groupType catalog, boundary/logo, box nodes, and manual arrows. + +--- + +## How to run it + +```bash +python3 scripts/pptx_builder.py layout input.json \ + --x 100 --y 180 --width 1720 --height 800 -o elements.json +``` + +- Input: one logical-structure JSON (see below). +- Output: `{ "elements": [...], "bbox": {...}, "warnings": [...] }`. +- Drop the `elements` array straight into a slide, or reference the whole file + with `{"type": "include", "src": "elements.json"}`. +- `targetArea` in the JSON (`{x, y, width, height}`) overrides the CLI flags. +- The engine scales the whole diagram to fit the target box, so author at any + scale — relationships matter, absolute sizes don't. + +--- + +## Logical structure JSON + +```json +{ + "direction": "horizontal", + "iconSize": 56, + "children": [ + {"id": "client", "icon": "assets:aws-internal/user_dark", "label": "Client"}, + {"id": "apigw", "icon": "icons:Arch_Amazon-API-Gateway_48", "label": "API Gateway"}, + {"id": "models", "direction": "vertical", "groupType": "generic-dashed", "label": "Model Serving", + "children": [ + {"id": "vision", "icon": "icons:Arch_Amazon-SageMaker_48", "label": "Vision"}, + {"id": "nlp", "icon": "icons:Arch_Amazon-SageMaker_48", "label": "NLP"}, + {"id": "tabular", "icon": "icons:Arch_Amazon-SageMaker_48", "label": "Tabular"} + ]} + ], + "connections": [ + {"from": "client", "to": "apigw"}, + {"from": "apigw", "to": "models", "label": "route"} + ] +} +``` + +### Node / group fields + +| Field | Where | Meaning | +|-------|-------|---------| +| `id` | every node | Unique id, referenced by `connections`. | +| `icon` | leaf | Icon source (`icons:...` / `assets:...`). | +| `box` | leaf | Text box instead of an icon (see arch-elements.md). | +| `label` | leaf / group | Caption. On a **group** it also forces the visible frame (see "Hiding group frames"). | +| `children` | group | Nested nodes (groups can nest groups). | +| `direction` | group | `"horizontal"` or `"vertical"` — main axis of this group. | +| `groupType` | group | Draws a frame (`generic-dashed`, `generic-solid`, `vpc`, …). Omit → no frame. | +| `iconSize` | root | Base icon size in px (engine scales to fit). | +| `align` | group | Cross-axis alignment: `center` (default) / `top` / `bottom` / `left` / `right`. | +| `reverse` | group | Reverse child order (flip flow direction). | + +### Connection fields + +| Field | Meaning | +|-------|---------| +| `from` / `to` | Endpoint ids. **May be a leaf id OR a group id** (see "Connect to a group"). | +| `label` | Optional arrow caption. | +| `fan` | `"merge"` to bundle this edge with its siblings onto one trunk (see "Fan merge"). | + +--- + +## The engine decides position — you decide relationships + +You set the group structure and the direction. **Within those constraints the +engine is free** to reorder children, pick arrow ports, and shift icons to: + +- cluster connected icons close together (shorter total wire length), +- minimize edge **crossings** and icon **pierces** (an arrow cutting through a + non-endpoint icon), +- keep the layout on-slide (overflow is ranked worse than any crossing). + +A multi-objective "judge" scores candidate layouts lexicographically: +`(off-slide overflow) → (weighted defects: pierce 1.5 > crossing 1.0 > backwards +0.7) → (soft: wire length + aspect)`. You don't invoke it; it runs inside the +engine. The practical consequence: **express the right structure and the +routing usually takes care of itself.** + +--- + +## Three techniques that make complex diagrams clean + +These are the levers to reach 0 crossings / 0 pierces on dense diagrams. Reach +for them in this order. + +### 1. Connect to a GROUP, not to every icon (many-to-many → many-to-one) + +Dense many-to-many wiring is the #1 source of crossings. Example: 3 compute +services each writing to 4 backend resources = 12 arrows that *must* cross. + +Instead of wiring every compute icon to every resource icon, **point one arrow +at the resource GROUP's box**. `from`/`to` accept a group id: + +```json +{"from": "web", "to": "data"}, +{"from": "api", "to": "data"}, +{"from": "worker", "to": "data"} +``` + +Here `data` is a group `{aurora, cache}`. Three arrows land on the group's edge +instead of nine landing on scattered icons. The engine excludes the group's own +member icons from that edge's obstacle set, so the arrow enters the box cleanly. + +This single change took the three-tier observed diagram from **21 crossings to +0**. Use it whenever a set of sources all talk to "the data tier" / "the +observability stack" / "the services layer" as a unit. + +### 2. Fan merge — bundle same-purpose arrows onto one trunk + +When several edges share a source (fan-out) or a target (fan-in) and represent +the *same purpose*, set `"fan": "merge"` on each. They unify onto one port and a +shared trunk, then split near the spokes: + +```json +{"from": "stepfn", "to": "lambda1", "fan": "merge"}, +{"from": "stepfn", "to": "lambda2", "fan": "merge"}, +{"from": "stepfn", "to": "lambda3", "fan": "merge"} +``` + +- Apply `fan: "merge"` to **every** edge in the bundle (≥2 needed). +- A merged bundle is a **hard constraint**: the engine locks the trunk and + routes everything else around it. +- **Self-protecting**: if the merged trunk would pierce any icon, the engine + silently rolls that bundle back to individual routing (a locked trunk can't be + cleaned up later, so it refuses to create one that cuts through an icon). + → You can safely mark a bundle `merge`; a geometrically-impossible merge just + won't happen. It is NOT a way to force a bad trunk. +- Don't blanket-apply `merge` to everything. Mark bundles that are genuinely "the + same flow" (a fan-out to workers, a fan-in to a queue). Unrelated edges sharing + an endpoint shouldn't merge. + +### 3. Branch nodes go perpendicular (keep the main line straight) + +A **degree-1 auxiliary node** sitting *on* the main flow line gets pierced by +the through-arrow. Humans place such a node perpendicular to the flow (a +fallback LLM below the router, an auth service above the load balancer). The +engine does this automatically **when you express the stack**: + +Put the auxiliary node in a small **invisible group** (no `groupType`, no +`label`) together with its anchor, oriented along the flow's main axis: + +```json +{"id": "router_col", "direction": "vertical", "children": [ + {"id": "router", "icon": "icons:Arch_AWS-Lambda_48", "label": "Router"}, + {"id": "bedrock", "icon": "icons:Arch_Amazon-Bedrock_48", "label": "Fallback LLM"} +]} +``` + +The engine recognizes that `router` is the flow node (it connects outside the +group) and `bedrock` is the branch, and keeps **router on the straight main +line** while bedrock hangs below. `API GW → Router → models` stays a straight +horizontal line; the fallback drops down. + +The engine also auto-detects and fixes this even without the wrapper group when +a clear branch sits between two flow nodes, but writing the invisible group is +the explicit, reliable way to control which side the branch lands on. + +--- + +## Hiding group frames + +`groupType` controls the frame: + +| JSON | Result | +|------|--------| +| `"groupType": "generic-dashed"` | dashed frame + label | +| `"groupType": "generic-solid"` | solid frame + label | +| *(no `groupType`)* | **no frame** — grouping still works for layout | + +Omitting `groupType` gives an **invisible group**: icons still cluster and you +can still aim a connection at the group, but no box is drawn. Use this for the +branch-node wrapper above, or to get a clean icons-and-arrows-only diagram. + +⚠️ A frameless group also drops its **label** (label renders on the frame +element). If you need the cluster named, keep a `groupType`. + +--- + +## Recipe: take a dense AWS diagram to 0 crossings + +1. **Lay out the main flow left→right (or top→bottom)** as the root `direction`. + Data pipelines and request flows read best horizontally; don't stack 5 tiers + vertically (it overflows and compresses). +2. **Group by tier/role**: services, data, observability each become a group. +3. **Replace many-to-many with many-to-one to the group box** (technique 1). +4. **Mark genuine bundles `fan: "merge"`** (technique 2). +5. **Wrap degree-1 auxiliaries (fallback, auth, cache-aside) in an invisible + perpendicular group** with their anchor (technique 3). +6. Run `layout`, read `warnings`, and check crossings/pierces. Iterate on + structure — not coordinates — if defects remain. + +### Worked examples (all reach 0 crossings / 0 pierces) + +- **3-tier web**: ECS group → Data group, ECS group → Observability group + (many-to-one); `alb → web/api/worker` as a `fan: "merge"` fan-out. 12 → 0. +- **Microservices + events**: `apigw → services` group, `bus → services` group, + `services → queue` group. Per-service `{Lambda, DB}` vertical stacks. 10 + pierces → 0. +- **ML inference**: `{router, bedrock}` invisible vertical column; `router → + models` group, `models → shared` group. Router stays on the straight flow, + Bedrock drops below. +- **Data pipeline**: horizontal flow `sources → kinesis → stepfn → lambdas → + s3 → analytics`; producers fan-in to Kinesis, stepfn fans-out to Lambdas, + Lambdas fan-in to S3 — all `fan: "merge"`. + +--- + +## Anti-patterns + +- **Wiring every icon to every icon** across two groups → use a group endpoint. +- **`fan: "merge"` on unrelated edges** that merely share an endpoint → only + bundle same-purpose flows. +- **Forcing a merge the engine rolled back** → the trunk pierced an icon; + restructure (split the bundle, reorder, or route to a group) instead. +- **Deep vertical nesting** (5+ stacked tiers) → overflows height and compresses + spacing; flip the root to horizontal. +- **Putting a degree-1 helper inline on the flow** and expecting no pierce → + wrap it perpendicular. + +--- + +## Reading the output + +- `warnings`: human-readable hints (tall/wide group, label overlap, residual + crossings). The builder's crossing warning is conservative and may flag a + shared fan trunk that is *not* a real crossing — trust the diagram and the QA + metric over that one warning. +- `bbox`: final bounding box after scale-to-fit. + +For an objective check, the QA harness reports crossings / pierces / overflow / +score: + +```bash +python3 scripts/layout_qa.py input.json --width 1720 --height 800 +``` + +--- +**Updated**: 2026-06-29 From b228f5e2556734e68885a30437e5c1a49855fea2 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Mon, 29 Jun 2026 14:52:48 +0900 Subject: [PATCH 48/79] feat(layout): detect & avoid group-frame pierces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A line that slices through a FRAMED group's box without connecting to that group or any icon inside it reads as broken (it looks like it belongs to the container but doesn't). New handling: - _count_group_pierces + _seg_crosses_box: count (edge, framed-group) pairs where an edge cuts a box it has no endpoint in. Surfaced as the group_pierces metric in layout_qa (weight 1.0 in the score) and on the CLI output. - _detour_around_pierces now treats framed group boxes as obstacles too, but commits a box detour ONLY when it FULLY clears the edge of every frame it cut (after == 0). A partial escape is rejected — that is the structural case (e.g. microservices bus routed through the services sub-group frames) where forcing a detour just adds wire/bends for a still-broken look. Verified: synthetic A->B through a middle group auto-detours to 0; microservices keeps its original tidy 7c/0p layout instead of regressing to 1c/1p/6gp. - pptx_builder emits a group-frame-pierce warning with concrete restructuring suggestions (reorder groups, move the container off the path, or connect to the group box many-to-one). - arch-layout-engine.md documents the metric, the auto-detour limit, and the fix recipe. Engine philosophy: fix it silently when a clean detour exists; otherwise leave the layout untouched and flag it so the structure gets fixed. --- skill/references/guides/arch-layout-engine.md | 48 ++++++- skill/scripts/layout_qa.py | 6 + skill/scripts/pptx_builder.py | 39 ++++++ skill/sdpm/layout/__init__.py | 126 +++++++++++++++++- 4 files changed, 206 insertions(+), 13 deletions(-) diff --git a/skill/references/guides/arch-layout-engine.md b/skill/references/guides/arch-layout-engine.md index a97e84a8..5b52000a 100644 --- a/skill/references/guides/arch-layout-engine.md +++ b/skill/references/guides/arch-layout-engine.md @@ -215,7 +215,10 @@ element). If you need the cluster named, keep a `groupType`. ### Worked examples (all reach 0 crossings / 0 pierces) - **3-tier web**: ECS group → Data group, ECS group → Observability group - (many-to-one); `alb → web/api/worker` as a `fan: "merge"` fan-out. 12 → 0. + (many-to-one); `alb → web/api/worker` as a `fan: "merge"` fan-out. 12 → 0 + crossings. ⚠️ Place Observability so `ECS→Obs` does not have to cross the + Data box (e.g. Obs as its own row below, not on the far side of Data) — laying + Data *between* ECS and Obs causes a group-frame pierce. - **Microservices + events**: `apigw → services` group, `bus → services` group, `services → queue` group. Per-service `{Lambda, DB}` vertical stacks. 10 pierces → 0. @@ -239,23 +242,56 @@ element). If you need the cluster named, keep a `groupType`. spacing; flip the root to horizontal. - **Putting a degree-1 helper inline on the flow** and expecting no pierce → wrap it perpendicular. +- **Routing a line past a framed group that sits across its path** → the line + cuts through an unrelated container's box (a *group-frame pierce*). See below. + +--- + +## Group-frame pierces (a line crossing an unrelated container) + +A line that slices through a **framed** group's box without connecting to that +group or any icon inside it reads as broken — it looks like it belongs to the +container but doesn't. The engine reports this as `group_pierces` and tries to +**auto-detour around the box, but ONLY when it can clear it completely**. If a +group sits squarely across the line's path (so no detour escapes without going +off-slide or through something else), the engine leaves the line straight and +emits a warning — because this is a **structural** problem you must fix, not a +routing one. + +When you see `Edge X→Y cuts through group "Z"`: + +1. **Reorder the groups** so X and Y are adjacent with no group between them. + (Three-tier: putting Observability *between* ECS and Data — or below — instead + of on the far side of Data, so `ECS→Obs` doesn't cross the Data box.) +2. **Move the offending group off the path** (different row/column). +3. **Connect to the group box itself** (many-to-one) if the flow genuinely + enters it — then it's no longer an unrelated container. + +Invisible groups (no `groupType`) have no box and never trigger this — only +framed groups do. --- ## Reading the output - `warnings`: human-readable hints (tall/wide group, label overlap, residual - crossings). The builder's crossing warning is conservative and may flag a - shared fan trunk that is *not* a real crossing — trust the diagram and the QA - metric over that one warning. + crossings, **group-frame pierces** with restructuring suggestions). The + builder's edge-crossing warning is conservative and may flag a shared fan + trunk that is *not* a real crossing — trust the diagram and the QA metric over + that one warning. - `bbox`: final bounding box after scale-to-fit. -For an objective check, the QA harness reports crossings / pierces / overflow / -score: +For an objective check, the QA harness reports crossings / pierces / +group_pierces / overflow / score: ```bash python3 scripts/layout_qa.py input.json --width 1720 --height 800 ``` +- `crossings` — edge segments that intersect. +- `pierces` — a line through a non-endpoint **icon**. +- `group_pierces` — a line through an unrelated **framed group box** (above). +- A clean diagram has all three at 0. + --- **Updated**: 2026-06-29 diff --git a/skill/scripts/layout_qa.py b/skill/scripts/layout_qa.py index be8c63a8..d7d56df0 100644 --- a/skill/scripts/layout_qa.py +++ b/skill/scripts/layout_qa.py @@ -29,6 +29,7 @@ _layout_collect, _layout_route_connections, _count_all_crossings, + _count_group_pierces, _seg_pierces_node, _find_node, ) @@ -117,6 +118,7 @@ def measure(tree, target_w=1720, target_h=800): nodes, groups, edges, rb = _build_layout(tree, target_w, target_h) crossings = _count_all_crossings(edges) + group_pierces = _count_group_pierces(edges, groups, nodes) pierces = [] diagonals = [] @@ -208,6 +210,7 @@ def measure(tree, target_w=1720, target_h=800): result = { "crossings": crossings, "pierces": len(pierces), + "group_pierces": group_pierces, "diagonals": len(diagonals), "bad_ports": len(bad_ports), "backwards": len(backwards), @@ -237,6 +240,7 @@ def measure(tree, target_w=1720, target_h=800): # a backwards segment is a softer wrongness than either. _W_CROSS = 1.0 _W_PIERCE = 1.5 +_W_GROUP_PIERCE = 1.0 _W_BACK = 0.7 _W_BADPORT = 0.5 @@ -272,6 +276,7 @@ def score(m): defects = ( _W_CROSS * m["crossings"] + _W_PIERCE * m["pierces"] + + _W_GROUP_PIERCE * m.get("group_pierces", 0) + _W_BACK * m["backwards"] + _W_BADPORT * (m["bad_ports"] + m["diagonals"]) ) @@ -298,6 +303,7 @@ def main(): return print(f"crossings={result['crossings']} pierces={result['pierces']} " + f"group_pierces={result['group_pierces']} " f"diagonals={result['diagonals']} bad_ports={result['bad_ports']} " f"backwards={result['backwards']} size={result['size']}") print(f"overflow={result['overflow']} wirelength={result['wirelength']} " diff --git a/skill/scripts/pptx_builder.py b/skill/scripts/pptx_builder.py index 7f0bc4aa..30d05c1f 100644 --- a/skill/scripts/pptx_builder.py +++ b/skill/scripts/pptx_builder.py @@ -35,10 +35,12 @@ match_elements, ) from sdpm.layout import ( + _group_member_ids, _layout_collect, _layout_route_connections, _layout_scale, _layout_translate, + _seg_crosses_box, box_to_elements, optimize_order, ) @@ -771,6 +773,43 @@ def build_root(): crossed = True break + # Group-frame pierce: an edge slices through a framed group's box without + # connecting to that group or any icon inside it. The engine auto-detours + # only when it can FULLY clear the box; a residual pierce here is a + # structural problem the author must fix (the line has nowhere clean to go + # because an unrelated container sits across its path). + gframe_reported = set() + for e in edges_out: + pts = e["points"] + if len(pts) < 2: + continue + efrom = e["from"].rsplit(".", 1)[-1] + eto = e["to"].rsplit(".", 1)[-1] + for gid, g in groups_out.items(): + if not g.get("groupType"): + continue + gshort = gid.rsplit(".", 1)[-1] + if efrom == gshort or eto == gshort: + continue + members = _group_member_ids(nodes_out, groups_out, gid) + if efrom in members or eto in members: + continue + key = (e["from"], e["to"], gid) + if key in gframe_reported: + continue + if any(_seg_crosses_box(pts[k], pts[k + 1], g["x"], g["y"], + g["width"], g["height"], 2) + for k in range(len(pts) - 1)): + glabel = g.get("label", gshort) + warnings.append( + f'Edge {e["from"]}→{e["to"]} cuts through group "{glabel}" ' + f'without connecting to it. Restructure so the line does not ' + f'cross an unrelated container: reorder groups so the endpoints ' + f'are adjacent (no group between them), move "{glabel}" off the ' + f'path, or connect to the group box itself (many-to-one) if the ' + f'flow really does enter it.') + gframe_reported.add(key) + # Structure suggestions: sibling size imbalance all_items = {} all_items.update(groups_out) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 64194df1..0c88e6ad 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -1352,12 +1352,14 @@ def _layout_route_connections(connections, nodes, groups=None): # diagonal is ever produced. Guarded on the global weighted total so the # per-edge slack can't accumulate into a net-worse layout. _det_before = _defect_weight((_count_all_crossings(edges), - _count_node_pierces(edges, nodes), + _count_node_pierces(edges, nodes) + + _count_group_pierces(edges, groups, nodes), _count_backwards(edges, nodes))) _det_snapshot = [list(map(list, e["points"])) for e in edges] - _detour_around_pierces(edges, nodes) + _detour_around_pierces(edges, nodes, groups) _det_after = _defect_weight((_count_all_crossings(edges), - _count_node_pierces(edges, nodes), + _count_node_pierces(edges, nodes) + + _count_group_pierces(edges, groups, nodes), _count_backwards(edges, nodes))) if _det_after > _det_before: for e, pts in zip(edges, _det_snapshot): @@ -2033,6 +2035,62 @@ def _count_node_pierces(edges, nodes): return count +_GROUP_FRAME_INSET = 2 + + +def _seg_crosses_box(p1, p2, bx, by, bw, bh, inset=0): + """True if axis-aligned segment p1-p2 passes through rectangle (bx,by,bw,bh). + + `inset` shrinks the rectangle so a segment merely running along the frame + edge (or a port landing exactly on it) is not counted as crossing through. + """ + x0, y0 = bx + inset, by + inset + x1, y1 = bx + bw - inset, by + bh - inset + ax, ay = p1 + bx2, by2 = p2 + if ax == bx2: # vertical segment + return x0 < ax < x1 and min(ay, by2) < y1 and max(ay, by2) > y0 + if ay == by2: # horizontal segment + return y0 < ay < y1 and min(ax, bx2) < x1 and max(ax, bx2) > x0 + return False + + +def _count_group_pierces(edges, groups, nodes): + """Count (edge, framed-group) pairs where an edge cuts through a group's + drawn frame without connecting to that group or any icon inside it. + + Only groups with a visible frame (``groupType``) are considered — an + invisible grouping has no box to violate. An edge is exempt for a group if + it starts/ends at that group OR at any of its member icons (those edges are + SUPPOSED to enter the box). Everything else slicing through the frame reads + as a stray line crossing an unrelated container, which looks broken. + """ + if not groups: + return 0 + framed = [(gid, g) for gid, g in groups.items() if g.get("groupType")] + if not framed: + return 0 + count = 0 + for e in edges: + pts = e["points"] + if len(pts) < 2: + continue + efrom = e["from"].rsplit(".", 1)[-1] + eto = e["to"].rsplit(".", 1)[-1] + for gid, g in framed: + gshort = gid.rsplit(".", 1)[-1] + if efrom == gshort or eto == gshort: + continue # edge connects to the group box itself + members = _group_member_ids(nodes, groups, gid) + if efrom in members or eto in members: + continue # edge connects to an icon inside this group + if any(_seg_crosses_box(pts[k], pts[k + 1], g["x"], g["y"], + g["width"], g["height"], _GROUP_FRAME_INSET) + for k in range(len(pts) - 1)): + count += 1 + return count + + def _count_backwards(edges, nodes): """Count edges whose first/last segment heads opposite to its port normal. @@ -2221,6 +2279,11 @@ def _optimize_single_bend(cand_pts, edge, edges, nodes): # most visually damaging (a line cutting through an icon), so it outweighs a # crossing; a backwards stub is the mildest. These mirror layout_qa.score(). _DEFECT_W = (1.0, 1.5, 0.7) # (crossings, pierces, backwards) +# A line cutting through a framed group's box (without connecting to it or any +# icon inside) reads as broken. Weighted like a crossing — bad, but lighter +# than an icon pierce — and only steers the detour pass (it cannot un-pierce a +# box by changing port sides, only by routing around it). +_W_GROUP_PIERCE_ENGINE = 1.0 # How many extra crossings a side change may introduce to clear a pierce. One # crossing is an acceptable price to stop a line cutting through an icon. _RESELECT_CROSS_SLACK = 1 @@ -2504,9 +2567,15 @@ def _jog_candidates(seg_a, seg_b, n): return out -def _detour_around_pierces(edges, nodes): +def _detour_around_pierces(edges, nodes, groups=None): """Splice obstacle jogs to clear pierces no side/port choice can fix. + Obstacles are both non-endpoint ICONS and framed GROUP boxes that an edge + cuts through without connecting to (group-frame pierce). The same bracket + jog clears either — a box is just a wider obstacle. Group pierces feed the + weighted cost so a detour around a frame is taken when it does not cost more + crossings/icon-pierces than it saves. + Greedy, one commit at a time, re-measuring the full live edge set after every tentative change. A jog is committed only if the global (crossings, pierces) tuple strictly improves AND crossings does not rise. @@ -2517,8 +2586,13 @@ def _detour_around_pierces(edges, nodes): if not edges: return edges + # Framed groups an edge may need to detour around (box obstacles). + framed = [(gid, g) for gid, g in (groups or {}).items() if g.get("groupType")] + def cost(es): - return (_count_all_crossings(es), _count_node_pierces(es, nodes), + gp = _count_group_pierces(es, groups, nodes) if framed else 0 + return (_count_all_crossings(es), + _count_node_pierces(es, nodes) + _W_GROUP_PIERCE_ENGINE * gp, _count_backwards(es, nodes)) for _ in range(_DETOUR_PASSES): @@ -2540,18 +2614,44 @@ def cost(es): if len(pts) < 2: continue ignore = {e["from"], e["to"]} + # Box obstacles this edge must avoid: framed groups it neither + # connects to nor has a member endpoint in. + efrom = e["from"].rsplit(".", 1)[-1] + eto = e["to"].rsplit(".", 1)[-1] + box_obstacles = [] + for gid, g in framed: + gshort = gid.rsplit(".", 1)[-1] + if efrom == gshort or eto == gshort: + continue + members = _group_member_ids(nodes, groups, gid) + if efrom in members or eto in members: + continue + box_obstacles.append(g) + # A box detour is only worth taking if it removes ALL frame pierces + # this edge causes — a partial detour that still clips a box just + # adds wire/bends for a still-broken look (the microservices + # bus-through-services case). Icon-pierce jogs keep their original + # partial-improvement behaviour. base = cost(edges) best_pts = None best_score = base # Scan each segment for a pierced obstacle; build jog candidates. for k in range(len(pts) - 1): seg_a, seg_b = pts[k], pts[k + 1] + # Obstacles for this segment: non-endpoint icons it pierces, plus + # framed group boxes it cuts through. + hit_obs = [] for nid, n in nodes.items(): short = nid.rsplit(".", 1)[-1] if nid in ignore or short in ignore: continue - if not _seg_pierces_node(seg_a, seg_b, n): - continue + if _seg_pierces_node(seg_a, seg_b, n): + hit_obs.append((n, False)) + for g in box_obstacles: + if _seg_crosses_box(seg_a, seg_b, g["x"], g["y"], + g["width"], g["height"], _GROUP_FRAME_INSET): + hit_obs.append((g, True)) + for n, is_box in hit_obs: for repl in _jog_candidates(seg_a, seg_b, n): cand = pts[:k + 1] + repl[1:-1] + pts[k + 1:] if not _is_axis_aligned(cand): @@ -2564,7 +2664,19 @@ def cost(es): saved = e["points"] e["points"] = cand score = cost(edges) + # A box detour must fully clear this edge's frame + # pierces; a partial escape is rejected so we never + # commit a longer, still-piercing route. + box_pierce_after = (_count_group_pierces([e], groups, nodes) + if box_obstacles else 0) e["points"] = saved + # Box detour: accept ONLY when it fully clears this + # edge of every frame it cut through. A still-clipping + # detour (after > 0) is the structural case the user + # must fix by restructuring — leave it untouched and let + # the warning flag it. + if is_box and box_pierce_after != 0: + continue # Judge by weighted defect (pierce 1.5 > cross 1.0): a jog # may add up to _RESELECT_CROSS_SLACK crossings to lift a # line off an icon it cuts through, which reads far worse From fd87a56507469bbd9165878ce51de73a078fa4ce Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Mon, 29 Jun 2026 21:44:22 +0900 Subject: [PATCH 49/79] fix(layout): rewrap sub-group frame after leaf-alignment shifts icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The leaf-alignment passes (_align_corresponding_leaves_*, _align_leaves_to_sibling_centers*) translate icons that live inside nested sub-groups to line them up with sibling groups, but never updated the sub-group's own box. The frame stayed at its pre-alignment position while its icons shifted, so they spilled outside the border — e.g. a Data Tier whose ElastiCache dropped ~55px below the solid frame, and adjacent group boxes appearing to overlap because their bounds were stale. Add _recompute_group_bbox and call it on each child group after the alignment passes (before the parent's own bbox is derived), re-deriving each group's box bottom-up from its now-moved descendants using the stored padding. Verified: omnichannel Data Tier now wraps all three icons (aurora/dynamo/cache all INSIDE); the three service groups render cleanly separated. No regression on the demo suite. Side effect (correct): with boxes now drawn at their true positions, the group-frame-pierce detector correctly surfaces catalog/cart/checkout->data cutting through the Order Pipeline group — a real structural issue that was masked while the box was mispositioned. --- skill/sdpm/layout/__init__.py | 38 +++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 0c88e6ad..cc7eb3ac 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -656,6 +656,16 @@ def sv(v): elif align == "center" and direction == "vertical": _align_leaves_to_sibling_centers_h(ordered) + # The alignment passes above translate LEAVES (including ones nested inside + # child sub-groups) without touching the sub-group's own box. Re-derive each + # child group's bbox bottom-up so its frame still wraps its (now-moved) + # icons — otherwise the box stays at the pre-alignment position and the + # shifted icons spill outside the frame (e.g. a Data Tier whose ElastiCache + # dropped below the solid border). + for c in children: + if c.get("children"): + _recompute_group_bbox(c) + min_x = min(c["_bindings"][0] - c["_margin"]["left"] for c in children) min_y = min(c["_bindings"][1] - c["_margin"]["top"] for c in children) max_x = max(c["_bindings"][0] + c["_bindings"][2] + c["_margin"]["right"] for c in children) @@ -671,6 +681,34 @@ def sv(v): node["_padding"] = padding +def _recompute_group_bbox(node): + """Re-derive a group's bbox from its children, bottom-up, in place. + + Used after the leaf-alignment passes move icons that live inside nested + sub-groups: those moves don't update the sub-group's own `_bindings`, so its + frame would otherwise stay where it was before the shift and no longer wrap + its icons. Recurses so deep nesting is corrected from the leaves up. Reuses + the group's stored `_padding` so the frame keeps its label band and margins. + """ + children = node.get("children") + if not children: + return + for c in children: + if c.get("children"): + _recompute_group_bbox(c) + padding = node.get("_padding", {"top": 0, "right": 0, "bottom": 0, "left": 0}) + min_x = min(c["_bindings"][0] - c["_margin"]["left"] for c in children) + min_y = min(c["_bindings"][1] - c["_margin"]["top"] for c in children) + max_x = max(c["_bindings"][0] + c["_bindings"][2] + c["_margin"]["right"] for c in children) + max_y = max(c["_bindings"][1] + c["_bindings"][3] + c["_margin"]["bottom"] for c in children) + node["_bindings"] = [ + min_x - padding["left"], + min_y - padding["top"], + (max_x - min_x) + padding["left"] + padding["right"], + (max_y - min_y) + padding["top"] + padding["bottom"], + ] + + def _ranges_overlap(lo1, hi1, lo2, hi2): """True if the 1-D intervals [lo1,hi1] and [lo2,hi2] overlap.""" return lo1 < hi2 and lo2 < hi1 From 7dd98c21dd15c8d2baf3f27a157926f406b4f181 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Mon, 29 Jun 2026 23:18:48 +0900 Subject: [PATCH 50/79] fix(layout): re-flow siblings after a sub-group box grows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bbox recompute fixed a grown sub-group's own frame, but a sibling placed against its old (smaller) bounds then overlapped it — a Data Tier that stretched to match a tall ECS column collided with the Observability group stacked below it inside the same parent. _recompute_group_bbox now calls _reflow_children_along_axis before deriving each node's box: consecutive children that overlap on the group's main axis are pushed forward (only forward, only along the axis) to restore the margin gap, preserving the cross-axis alignment the leaf passes established. No-op when children already clear each other, so layouts that didn't grow are untouched. Verified: omnichannel Data Tier (icons all inside) and Observability are now cleanly separated (36px gap, 0 overlap); full demo suite unchanged. --- skill/sdpm/layout/__init__.py | 38 +++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index cc7eb3ac..01aed785 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -689,6 +689,12 @@ def _recompute_group_bbox(node): frame would otherwise stay where it was before the shift and no longer wrap its icons. Recurses so deep nesting is corrected from the leaves up. Reuses the group's stored `_padding` so the frame keeps its label band and margins. + + A grown sub-group can also start OVERLAPPING a sibling that was placed + against its old (smaller) bounds — e.g. a Data Tier that stretched to match + a tall sibling column now collides with the Observability group stacked + below it. After re-deriving child boxes we re-flow this node's children + along its own axis to restore the margin gaps, then derive this node's box. """ children = node.get("children") if not children: @@ -696,6 +702,7 @@ def _recompute_group_bbox(node): for c in children: if c.get("children"): _recompute_group_bbox(c) + _reflow_children_along_axis(node, children) padding = node.get("_padding", {"top": 0, "right": 0, "bottom": 0, "left": 0}) min_x = min(c["_bindings"][0] - c["_margin"]["left"] for c in children) min_y = min(c["_bindings"][1] - c["_margin"]["top"] for c in children) @@ -709,6 +716,37 @@ def _recompute_group_bbox(node): ] +def _reflow_children_along_axis(node, children): + """Push apart consecutive children that overlap on the group's main axis. + + Only moves along the layout axis (vertical group → shift Y, horizontal → + shift X) and only ever forward (never pulls a child back), so the cross-axis + alignment the leaf passes just established is preserved. A no-op when the + children already clear each other — the common case — so it cannot disturb + a layout that didn't grow. + """ + direction = node.get("direction", "horizontal") + if len(children) < 2: + return + reverse = node.get("reverse", False) + ordered = list(reversed(children)) if reverse else children + for i in range(1, len(ordered)): + prev = ordered[i - 1]["_bindings"] + pm = ordered[i - 1]["_margin"] + cur = ordered[i]["_bindings"] + cm = ordered[i]["_margin"] + if direction == "vertical": + need_top = prev[1] + prev[3] + pm["bottom"] + cm["top"] + delta = need_top - cur[1] + if delta > 0: + _layout_translate(ordered[i], 0, delta) + else: + need_left = prev[0] + prev[2] + pm["right"] + cm["left"] + delta = need_left - cur[0] + if delta > 0: + _layout_translate(ordered[i], delta, 0) + + def _ranges_overlap(lo1, hi1, lo2, hi2): """True if the 1-D intervals [lo1,hi1] and [lo2,hi2] overlap.""" return lo1 < hi2 and lo2 < hi1 From f315023d273e442845e4018d638215aecc30fa5a Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Tue, 30 Jun 2026 11:37:08 +0900 Subject: [PATCH 51/79] feat(layout): compress only the overflowing group, not the whole slide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The global scale-to-fit applied ONE vertical factor to the entire slide, so a single tall group (e.g. a 5-service Microservices column) forced every short sibling — Edge, Observability, single icons — to be squashed to the same unreadable factor. The cross axis of a row of groups is independent: only the tallest sibling needs compressing. Add assign_cross_axis_fit: for a horizontal root that overflows in height (or a vertical root overflowing in width), set a per-group _vscale/_hscale ONLY on the children that exceed the target, sized so each just fits, leaving short siblings at 1.0. _layout_scale multiplies the per-node factor into the inherited one. Wired into cmd_layout and layout_qa between the natural-size pass and the global fit loop. event-driven-saas: Microservices group spacing tightened (vscale 0.85), short Edge/Observability groups no longer crushed. Demo suite unchanged. Respects the LLM's structure: this only tightens SPACING within a group the LLM made tall. When a group is so dense that spacing compression can't fit it (agentic's 4-team vertical agent tower of stacked icons), the engine does NOT silently re-flow it — it leaves the layout and emits the existing 'group is tall, consider direction: horizontal' warning for the author to act on. --- skill/scripts/layout_qa.py | 6 ++++ skill/scripts/pptx_builder.py | 8 ++++++ skill/sdpm/layout/__init__.py | 53 +++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+) diff --git a/skill/scripts/layout_qa.py b/skill/scripts/layout_qa.py index d7d56df0..adcd9d11 100644 --- a/skill/scripts/layout_qa.py +++ b/skill/scripts/layout_qa.py @@ -32,6 +32,7 @@ _count_group_pierces, _seg_pierces_node, _find_node, + assign_cross_axis_fit, ) _TOL = 2 @@ -53,6 +54,11 @@ def build_root(): root = build_root() _layout_scale(root, direction, align) + # Per-group cross-axis fit (mirror cmd_layout): compress only the sibling + # groups that overflow the cross axis, before the global fit loop. + if assign_cross_axis_fit(tree, root, target_w, target_h): + root = build_root() + _layout_scale(root, direction, align) cum_h = cum_v = 1.0 if target_w or target_h: for _ in range(10): diff --git a/skill/scripts/pptx_builder.py b/skill/scripts/pptx_builder.py index 30d05c1f..c81664f9 100644 --- a/skill/scripts/pptx_builder.py +++ b/skill/scripts/pptx_builder.py @@ -41,6 +41,7 @@ _layout_scale, _layout_translate, _seg_crosses_box, + assign_cross_axis_fit, box_to_elements, optimize_order, ) @@ -483,6 +484,13 @@ def build_root(): target_w = args.width target_h = args.height + # Per-group cross-axis fit: compress ONLY the sibling groups that overflow + # the cross axis (a single tall group in a horizontal row), so short + # siblings aren't crushed by a global squash. Re-run scale to apply. + if assign_cross_axis_fit(tree, root, target_w, target_h): + root = build_root() + _layout_scale(root, direction, align) + cumulative_sh = 1.0 cumulative_sv = 1.0 if target_w or target_h: diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 01aed785..aef138de 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -559,6 +559,12 @@ def _layout_scale(node, parent_dir="horizontal", parent_align="center", spacing_ is_group = len(children) > 0 icon_size = node.get("iconSize", 60) + # A per-node spacing scale lets the fit pass compress ONE overflowing group + # (and its descendants) without touching sibling groups that already fit. + # Multiplies into the inherited factor so nested overrides compose. + spacing_scale_h *= node.get("_hscale", 1.0) + spacing_scale_v *= node.get("_vscale", 1.0) + def sh(v): return max(10, round(v * spacing_scale_h)) def sv(v): @@ -681,6 +687,53 @@ def sv(v): node["_padding"] = padding +def assign_cross_axis_fit(tree, root, target_w, target_h): + """Compress only the sibling groups that overflow the cross axis. + + For a horizontal root the children sit side by side: total WIDTH is their + sum (handled by the global fit) but total HEIGHT is just the tallest child. + A single tall group (e.g. a 4x2 agent grid at 900px) would otherwise force a + global vertical squash that crushes every short sibling (Entry, Orchestration) + to an unreadable 11%. Instead we set a per-group `_vscale` ONLY on the + children that exceed the target height, sized so each just fits, and leave + the rest at 1.0. Symmetric for a vertical root overflowing in width. + + Writes `_vscale`/`_hscale` onto the matching nodes in `tree` (the source the + caller rebuilds the root from) and returns True if anything was assigned, so + the caller knows to re-run `_layout_scale`. + """ + direction = tree.get("direction", "horizontal") + src_children = tree.get("children", tree.get("nodes", [])) + laid = root.get("children", []) + if len(laid) != len(src_children): + return False + changed = False + if direction == "horizontal" and target_h: + # cross axis = height; compress children taller than the target band. + for src, node in zip(src_children, laid): + if not node.get("children"): + continue # a bare icon can't be compressed + h = node["_bindings"][3] + if h > target_h: + # scale the group's spacing so its laid-out height meets target. + # bias slightly under 1.0 of target to leave a margin. + cur = src.get("_vscale", 1.0) + factor = (target_h / h) * 0.98 + src["_vscale"] = cur * factor + changed = True + elif direction == "vertical" and target_w: + for src, node in zip(src_children, laid): + if not node.get("children"): + continue + w = node["_bindings"][2] + if w > target_w: + cur = src.get("_hscale", 1.0) + factor = (target_w / w) * 0.98 + src["_hscale"] = cur * factor + changed = True + return changed + + def _recompute_group_bbox(node): """Re-derive a group's bbox from its children, bottom-up, in place. From 4e2ca7f13425b44c92094f02785827ca6da0c8e8 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Tue, 30 Jun 2026 12:49:50 +0900 Subject: [PATCH 52/79] feat(layout): cancel cross-axis squash on groups that already fit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the earlier assign_cross_axis_fit (pre-fit spacing tighten) with a post-fit correction that better matches the goal: when one oversized group forces a global cross-axis squash, only that group should shrink — sibling groups that fit on their own must keep their natural size, even if the slide then overflows. measure_natural_child_sizes records each top-level group's natural (w,h) before the global fit. After the fit loop, cancel_cross_axis_squash gives a compensating _vscale (=1/cum_v) to every horizontal-root child whose natural height already fit target_h — restoring its size while the tall group keeps the squash. Symmetric for vertical roots / width. Gated on cum<0.97 so it is a no-op unless the cross axis was actually compressed. agentic-workflow: Entry/Orchestration groups were crushed to ~15% by the tall 4-team agent tower's squash; now they render at natural height while the tower stays oversized (slide overflows, flagged by the existing 'group is tall, consider horizontal' warning — the author's call, per the keep-LLM-structure principle). Full demo suite byte-identical (the cum<0.97 gate means no change wherever the cross axis already fit). --- skill/scripts/layout_qa.py | 14 +++--- skill/scripts/pptx_builder.py | 20 ++++++--- skill/sdpm/layout/__init__.py | 85 ++++++++++++++++++++--------------- 3 files changed, 69 insertions(+), 50 deletions(-) diff --git a/skill/scripts/layout_qa.py b/skill/scripts/layout_qa.py index adcd9d11..0bb24ac5 100644 --- a/skill/scripts/layout_qa.py +++ b/skill/scripts/layout_qa.py @@ -32,7 +32,8 @@ _count_group_pierces, _seg_pierces_node, _find_node, - assign_cross_axis_fit, + cancel_cross_axis_squash, + measure_natural_child_sizes, ) _TOL = 2 @@ -54,11 +55,7 @@ def build_root(): root = build_root() _layout_scale(root, direction, align) - # Per-group cross-axis fit (mirror cmd_layout): compress only the sibling - # groups that overflow the cross axis, before the global fit loop. - if assign_cross_axis_fit(tree, root, target_w, target_h): - root = build_root() - _layout_scale(root, direction, align) + natural_sizes = measure_natural_child_sizes(tree, root) cum_h = cum_v = 1.0 if target_w or target_h: for _ in range(10): @@ -71,6 +68,11 @@ def build_root(): cum_v *= sy root = build_root() _layout_scale(root, direction, align, cum_h, cum_v) + # Cancel cross-axis squash on groups that fit (mirror cmd_layout). + if cancel_cross_axis_squash(tree, natural_sizes, cum_h, cum_v, + target_w, target_h): + root = build_root() + _layout_scale(root, direction, align, cum_h, cum_v) rb = root["_bindings"] _layout_translate(root, -rb[0], -rb[1]) diff --git a/skill/scripts/pptx_builder.py b/skill/scripts/pptx_builder.py index c81664f9..4d08f1ef 100644 --- a/skill/scripts/pptx_builder.py +++ b/skill/scripts/pptx_builder.py @@ -41,8 +41,9 @@ _layout_scale, _layout_translate, _seg_crosses_box, - assign_cross_axis_fit, box_to_elements, + cancel_cross_axis_squash, + measure_natural_child_sizes, optimize_order, ) from sdpm.preview.backend import _is_wsl @@ -484,12 +485,9 @@ def build_root(): target_w = args.width target_h = args.height - # Per-group cross-axis fit: compress ONLY the sibling groups that overflow - # the cross axis (a single tall group in a horizontal row), so short - # siblings aren't crushed by a global squash. Re-run scale to apply. - if assign_cross_axis_fit(tree, root, target_w, target_h): - root = build_root() - _layout_scale(root, direction, align) + # Record each top-level group's NATURAL cross-axis size so we can later tell + # which groups fit on their own (before any global squash). + natural_sizes = measure_natural_child_sizes(tree, root) cumulative_sh = 1.0 cumulative_sv = 1.0 @@ -504,6 +502,14 @@ def build_root(): cumulative_sv *= sy root = build_root() _layout_scale(root, direction, align, cumulative_sh, cumulative_sv) + # A single oversized group forces a global cross-axis squash that would + # crush short siblings. Cancel that squash on the groups that already + # fit (the slide may overflow — that's the oversized group's problem, + # flagged by a warning — but the groups that fit stay readable). + if cancel_cross_axis_squash(tree, natural_sizes, cumulative_sh, + cumulative_sv, target_w, target_h): + root = build_root() + _layout_scale(root, direction, align, cumulative_sh, cumulative_sv) args._cumulative_scale = min(cumulative_sh, cumulative_sv) rb = root["_bindings"] diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index aef138de..48f8fa65 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -687,53 +687,64 @@ def sv(v): node["_padding"] = padding -def assign_cross_axis_fit(tree, root, target_w, target_h): - """Compress only the sibling groups that overflow the cross axis. - - For a horizontal root the children sit side by side: total WIDTH is their - sum (handled by the global fit) but total HEIGHT is just the tallest child. - A single tall group (e.g. a 4x2 agent grid at 900px) would otherwise force a - global vertical squash that crushes every short sibling (Entry, Orchestration) - to an unreadable 11%. Instead we set a per-group `_vscale` ONLY on the - children that exceed the target height, sized so each just fits, and leave - the rest at 1.0. Symmetric for a vertical root overflowing in width. - - Writes `_vscale`/`_hscale` onto the matching nodes in `tree` (the source the - caller rebuilds the root from) and returns True if anything was assigned, so - the caller knows to re-run `_layout_scale`. +def cancel_cross_axis_squash(tree, natural_sizes, cum_h, cum_v, target_w, target_h): + """Undo the global cross-axis squash on sibling groups that already fit. + + The global fit applies ONE scale per axis. On the CROSS axis (height for a + horizontal root, width for a vertical one) sibling groups don't sum — the + total is just the largest. So when one tall group (e.g. a 4-team agent tower) + forces the whole slide to squash vertically, short siblings (Entry, + Orchestration) get dragged down with it and turn unreadable. + + The cross axis can't be made to fit by scaling alone if the tall group is + genuinely too big (its icons, not just gaps, fill the height) — the layout + WILL overflow, and that's acceptable; the overflow warning tells the author + to restructure the tall group. What we refuse to accept is crushing the + groups that DID fit. So for each top-level group whose NATURAL cross-axis + size already fit the target, we cancel the global cross-axis squash by + giving it a compensating `_vscale`/`_hscale` ( = 1/cum ), restoring its + natural size; the oversized group keeps the squash. Mutates `tree`; returns + True if any compensation was assigned (caller re-runs `_layout_scale`). """ direction = tree.get("direction", "horizontal") src_children = tree.get("children", tree.get("nodes", [])) - laid = root.get("children", []) - if len(laid) != len(src_children): - return False changed = False - if direction == "horizontal" and target_h: - # cross axis = height; compress children taller than the target band. - for src, node in zip(src_children, laid): - if not node.get("children"): - continue # a bare icon can't be compressed - h = node["_bindings"][3] - if h > target_h: - # scale the group's spacing so its laid-out height meets target. - # bias slightly under 1.0 of target to leave a margin. - cur = src.get("_vscale", 1.0) - factor = (target_h / h) * 0.98 - src["_vscale"] = cur * factor + # Only meaningful when the cross axis was actually compressed (<1). + if direction == "horizontal" and target_h and cum_v and cum_v < 0.97: + for src in src_children: + if not src.get("children"): + continue + if natural_sizes.get(id(src), (0, 0))[1] <= target_h: + src["_vscale"] = src.get("_vscale", 1.0) * (1.0 / cum_v) changed = True - elif direction == "vertical" and target_w: - for src, node in zip(src_children, laid): - if not node.get("children"): + elif direction == "vertical" and target_w and cum_h and cum_h < 0.97: + for src in src_children: + if not src.get("children"): continue - w = node["_bindings"][2] - if w > target_w: - cur = src.get("_hscale", 1.0) - factor = (target_w / w) * 0.98 - src["_hscale"] = cur * factor + if natural_sizes.get(id(src), (0, 0))[0] <= target_w: + src["_hscale"] = src.get("_hscale", 1.0) * (1.0 / cum_h) changed = True return changed +def measure_natural_child_sizes(tree, root): + """Map each top-level source child -> its natural (w, h) before global fit. + + Keyed by id() of the source-child dict so it survives the deepcopy/rebuild + cycle as long as the caller passes the SAME tree dicts. Used by + cancel_cross_axis_squash to tell which groups fit on their own. + """ + out = {} + src_children = tree.get("children", tree.get("nodes", [])) + laid = root.get("children", []) + if len(laid) != len(src_children): + return out + for src, node in zip(src_children, laid): + b = node["_bindings"] + out[id(src)] = (b[2], b[3]) + return out + + def _recompute_group_bbox(node): """Re-derive a group's bbox from its children, bottom-up, in place. From 289fcbceae7e27feb7897758ea3e70e477cdd1b8 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Tue, 30 Jun 2026 13:23:36 +0900 Subject: [PATCH 53/79] fix(layout): straighten group-endpoint arrows + stop cross-group row drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent fixes for L-bent arrows between horizontally adjacent groups (e.g. ml-feature-platform's "Step Functions → Processing" and "Processing → Shared"): 1. Row-alignment was collecting nested vertical sub-groups as peers of top-level columns. A deep {Inference, Bedrock} branch inside the Processing column got row-aligned with the sibling Shared/Analytics columns, dragging Shared's center off the flow line. Collect only the OUTERMOST vertical (and horizontal) groups so a column's internal structure is never aligned to its siblings. 2. A connection to a GROUP box defaults its port to the box center, so a node→tall-group (or unequal-height group→group) edge bends into an L even when a straight run fits. New T15 pass straightens such solo group-endpoint edges by attaching at the smaller endpoint's center, guarded on the weighted defect total. Many-to-one bundles stay owned by the group-bus pass (T14). Full demo suite: no regressions; ml-feature-platform soft score 0.902→0.807, omnichannel cross/pierce/gpierce 3/3/3→0/0/1. --- skill/references/guides/arch-layout-engine.md | 9 +- skill/sdpm/layout/__init__.py | 122 +++++++++++++++++- 2 files changed, 127 insertions(+), 4 deletions(-) diff --git a/skill/references/guides/arch-layout-engine.md b/skill/references/guides/arch-layout-engine.md index 5b52000a..bb7c6749 100644 --- a/skill/references/guides/arch-layout-engine.md +++ b/skill/references/guides/arch-layout-engine.md @@ -91,7 +91,12 @@ engine is free** to reorder children, pick arrow ports, and shift icons to: - cluster connected icons close together (shorter total wire length), - minimize edge **crossings** and icon **pierces** (an arrow cutting through a non-endpoint icon), -- keep the layout on-slide (overflow is ranked worse than any crossing). +- keep the layout on-slide (overflow is ranked worse than any crossing), +- **straighten arrows where a straight run fits** — a solo arrow between two + side-by-side endpoints (icon↔icon, icon↔group box, or two group boxes of + unequal height) is drawn as one straight line, not an L-bend, by attaching it + at the smaller endpoint's center. Many-to-one arrows into a group box stay + bundled as a parallel bus instead (so they don't all pile onto one point). A multi-objective "judge" scores candidate layouts lexicographically: `(off-slide overflow) → (weighted defects: pierce 1.5 > crossing 1.0 > backwards @@ -294,4 +299,4 @@ python3 scripts/layout_qa.py input.json --width 1720 --height 800 - A clean diagram has all three at 0. --- -**Updated**: 2026-06-29 +**Updated**: 2026-06-30 diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 48f8fa65..389d43ff 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -930,25 +930,41 @@ def _align_corresponding_leaves_x(ordered): def _collect_vertical_groups(node, out): - """Recursively collect vertical groups with their direct leaves.""" + """Collect the OUTERMOST vertical groups with their direct leaves. + + Descends through horizontal groups (their vertical children may be genuine + side-by-side peers) but STOPS at a vertical group: a vertical sub-group + nested inside another vertical column is that column's internal structure + (e.g. an {Inference, Bedrock} branch deep inside a Processing column), NOT a + peer of the column's top-level siblings. Collecting it would let row + alignment drag an unrelated top-level group down to match a deeply-nested + one — the bug that bent group-to-group arrows into L shapes. + """ if not node.get("children"): return if node.get("direction", "horizontal") == "vertical": leaves = [c for c in node["children"] if not c.get("children")] if leaves: out.append((node, leaves)) + return # internals of a vertical column are not peers of its siblings for child in node.get("children", []): _collect_vertical_groups(child, out) def _collect_horizontal_groups(node, out): - """Recursively collect horizontal groups with their direct leaves.""" + """Collect the OUTERMOST horizontal groups with their direct leaves. + + Mirror of _collect_vertical_groups: descends through vertical groups but + stops at a horizontal group, so a horizontal sub-row nested inside another + horizontal row is not column-aligned with that row's top-level siblings. + """ if not node.get("children"): return if node.get("direction", "horizontal") == "horizontal": leaves = [c for c in node["children"] if not c.get("children")] if leaves: out.append((node, leaves)) + return # internals of a horizontal row are not peers of its siblings for child in node.get("children", []): _collect_horizontal_groups(child, out) @@ -1524,6 +1540,108 @@ def _layout_route_connections(connections, nodes, groups=None): # global crossing count. _align_group_bus(edges, nodes, groups) + # T15: Straighten solo group-endpoint edges. A connection to a GROUP box + # defaults its port to the box center, so a node→tall-group (or small-group→ + # tall-group) edge bends into an L even when a straight run fits inside both + # facing edges — the "Step Functions → Processing" / "Processing → Shared" + # L-bends. Slide the port that has the larger box to the smaller endpoint's + # center so the arrow becomes a clean straight line. Guarded; bundles owned + # by the group bus (T14) are left alone. + _straighten_group_edges(edges, nodes, groups) + + return edges + + +def _straighten_group_edges(edges, nodes, groups): + """Make a solo group-endpoint edge a straight line when one fits. + + A connection whose endpoint is a GROUP box gets its port at the box center, + so when the two endpoints differ in cross-axis extent (a 60px icon vs a + 765px column, or two columns of unequal height) the elbow router bends the + edge even though a single straight segment would fit inside both facing + edges. For each such edge whose two ports sit on facing horizontal (or + facing vertical) sides, we choose a common cross-axis coordinate that lies + inside BOTH endpoints' spans — preferring the SMALLER endpoint's center, so + single icons and small boxes attach at their visual middle and the larger + box absorbs the offset — and re-emit a straight 2-point edge. + + Left untouched: fan trunks (the merge is a hard constraint) and any edge + sharing a group endpoint with another edge (a many-to-one bundle owned by + the group-bus pass, T14). Guarded on the global weighted defect total so + straightening can never add a crossing, icon pierce, or frame pierce. + """ + if not edges: + return edges + + # Count edges per group endpoint so many-to-one bundles stay with the bus. + grp_use = {} + for e in edges: + if e.get("_src_group"): + grp_use[("src", e["_src_group"])] = grp_use.get(("src", e["_src_group"]), 0) + 1 + if e.get("_dst_group"): + grp_use[("dst", e["_dst_group"])] = grp_use.get(("dst", e["_dst_group"]), 0) + 1 + + def weighted(es): + return _defect_weight((_count_all_crossings(es), + _count_node_pierces(es, nodes) + + _count_group_pierces(es, groups, nodes), + _count_backwards(es, nodes))) + + for e in edges: + if e.get("_fan_locked") or e.get("_fanout"): + continue + pts = e["points"] + if len(pts) < 2: + continue + # Only group-endpoint edges suffer the box-center kink; node→node edges + # are already snapped straight by the elbow router when they line up. + if not (e.get("_src_group") or e.get("_dst_group")): + continue + if e.get("_src_group") and grp_use.get(("src", e["_src_group"]), 0) >= 2: + continue + if e.get("_dst_group") and grp_use.get(("dst", e["_dst_group"]), 0) >= 2: + continue + s_geom, _ = _find_endpoint(nodes, groups, e["from"]) + d_geom, _ = _find_endpoint(nodes, groups, e["to"]) + if not s_geom or not d_geom: + continue + first_h = abs(pts[0][1] - pts[1][1]) <= 2 + first_v = abs(pts[0][0] - pts[1][0]) <= 2 + last_h = abs(pts[-1][1] - pts[-2][1]) <= 2 + last_v = abs(pts[-1][0] - pts[-2][0]) <= 2 + + new_pts = None + if first_h and last_h and abs(pts[0][1] - pts[-1][1]) > 2: + # Both ports on left/right edges → straighten on a common Y. + lo = max(s_geom["y"], d_geom["y"]) + hi = min(s_geom["y"] + s_geom["height"], d_geom["y"] + d_geom["height"]) + if hi - lo > 2: + if s_geom["height"] <= d_geom["height"]: + c = s_geom["y"] + s_geom["height"] / 2 + else: + c = d_geom["y"] + d_geom["height"] / 2 + y = round(min(max(c, lo + 1), hi - 1)) + new_pts = [[pts[0][0], y], [pts[-1][0], y]] + elif first_v and last_v and abs(pts[0][0] - pts[-1][0]) > 2: + # Both ports on top/bottom edges → straighten on a common X. + lo = max(s_geom["x"], d_geom["x"]) + hi = min(s_geom["x"] + s_geom["width"], d_geom["x"] + d_geom["width"]) + if hi - lo > 2: + if s_geom["width"] <= d_geom["width"]: + c = s_geom["x"] + s_geom["width"] / 2 + else: + c = d_geom["x"] + d_geom["width"] / 2 + x = round(min(max(c, lo + 1), hi - 1)) + new_pts = [[x, pts[0][1]], [x, pts[-1][1]]] + if new_pts is None: + continue + + snap = [list(map(list, ee["points"])) for ee in edges] + before = weighted(edges) + e["points"] = new_pts + if weighted(edges) > before: + for ee, p in zip(edges, snap): + ee["points"] = p return edges From c9bde54c1084f2e935aa666e588c952fa9b67a85 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Tue, 30 Jun 2026 13:42:58 +0900 Subject: [PATCH 54/79] fix(layout): row-align only clean columns so group centers stay aligned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Row alignment was treating a column with a nested sub-group (e.g. Processing = three Lambdas + an {Inference, Bedrock} branch) as a peer of a clean sibling column. The mixed column's leaves bunch at the top while the clean column spreads them over its full height, so aligning row-by-row dragged the mixed column's box center off the shared flow line (stages cy 256→412). Restrict row/column alignment to CLEAN columns where every direct child is a bare leaf (len(leaves)==len(children)), so row N means the same thing in every aligned column. Result: ml-feature-platform group centers now align (~412 across all top-level groups) with the main flow staying straight; 8/10 demos byte-identical, omnichannel overflow bucket improves 1→0. --- skill/sdpm/layout/__init__.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 389d43ff..cbc198a9 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -944,7 +944,13 @@ def _collect_vertical_groups(node, out): return if node.get("direction", "horizontal") == "vertical": leaves = [c for c in node["children"] if not c.get("children")] - if leaves: + # Only a CLEAN column (every direct child is a bare leaf) can be + # row-aligned: row N must mean the same thing in every column. A column + # that also holds a sub-group (e.g. Processing = three Lambdas + an + # {Inference, Bedrock} branch) has its leaves bunched at the top while a + # peer column spreads them over its full height — aligning row-by-row + # then drags the mixed column's box center off the flow line. Skip it. + if leaves and len(leaves) == len(node["children"]): out.append((node, leaves)) return # internals of a vertical column are not peers of its siblings for child in node.get("children", []): @@ -962,7 +968,9 @@ def _collect_horizontal_groups(node, out): return if node.get("direction", "horizontal") == "horizontal": leaves = [c for c in node["children"] if not c.get("children")] - if leaves: + # Only a CLEAN row (every direct child is a bare leaf) can be + # column-aligned — see _collect_vertical_groups for the rationale. + if leaves and len(leaves) == len(node["children"]): out.append((node, leaves)) return # internals of a horizontal row are not peers of its siblings for child in node.get("children", []): From ff55fc62dd3dec8ab2c3cbc1b956b0713a3ca542 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Tue, 30 Jun 2026 13:51:54 +0900 Subject: [PATCH 55/79] feat(layout): push fan split/merge outside the hub's framed group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fan-out (or fan-in) whose hub icon lives inside a drawn box bent its shared trunk WHILE still inside the frame — the bundle visibly split within an unrelated container (EventBridge fanning to four agents bent at x=687, inside the Orchestration box ending at x=782). After computing the trunk coordinate, push it past the frame edge the bundle exits through (+_FAN_GROUP_CLEARANCE), clamped short of the nearest spoke, so the bundle leaves the box as one line and fans out only beyond it. New helpers _enclosing_framed_group / _push_trunk_outside_group; only fires when the hub is a node enclosed by a framed group on the exit side. agentic-workflow EB→agents trunk x=687→804 (outside the box); all 10 demos byte-identical QA scores (pure aesthetics, no crossing/pierce change). --- skill/sdpm/layout/__init__.py | 67 +++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index cbc198a9..f525bead 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -3391,6 +3391,60 @@ def _update_bend_x(points, old_x, new_x): _FAN_BEND_MARGIN = 30 +# How far past a framed group's edge the fan trunk is pushed so the split/merge +# happens clearly outside the box, not flush against the frame. +_FAN_GROUP_CLEARANCE = 22 + + +def _enclosing_framed_group(groups, nodes, node_id): + """Return the geometry of the framed group that directly encloses node_id. + + A fan hub that lives inside a drawn box should split/merge OUTSIDE that box. + We find the framed (groupType) group whose member set contains node_id and, + if several nest, pick the SMALLEST (innermost) by area — that is the frame + the trunk must clear first. Returns the group dict or None. + """ + if not groups: + return None + short = node_id.rsplit(".", 1)[-1] + best = None + for gid, g in groups.items(): + if not g.get("groupType"): + continue + members = _group_member_ids(nodes, groups, gid) + if short in members: + area = g["width"] * g["height"] + if best is None or area < best[0]: + best = (area, g) + return best[1] if best else None + + +def _push_trunk_outside_group(trunk_v, side, vertical, nearest, hbox): + """Shift a fan trunk coordinate to just past the hub's enclosing frame. + + The trunk is the shared line where the bundle splits (fan-out) or merges + (fan-in). When the hub sits inside a framed box, a trunk flush against the + icon still bends inside the frame. Push it past the frame edge it exits + through (by _FAN_GROUP_CLEARANCE), but clamp so it never reaches/over­shoots + the nearest spoke — leaving the spoke side of the gap for the actual fan. + No-op when there is no enclosing frame or the push would cross the spoke. + """ + if hbox is None: + return trunk_v + if vertical: + edge = hbox["y"] + hbox["height"] if side == "bottom" else hbox["y"] + else: + edge = hbox["x"] + hbox["width"] if side == "right" else hbox["x"] + if side in ("right", "bottom"): + target = edge + _FAN_GROUP_CLEARANCE + # only push outward, and stay short of the nearest spoke + if target > trunk_v and target < nearest: + return target + else: # left / top — frame edge is on the smaller-coordinate side + target = edge - _FAN_GROUP_CLEARANCE + if target < trunk_v and target > nearest: + return target + return trunk_v def _fan_side_vote(edges, conn_sides, indices, mode, nodes=None, groups=None): @@ -3501,6 +3555,19 @@ def _gap_trunk(p0, nearest): nearest = min(spoke_hs) if side == "right" else max(spoke_hs) trunk_v = _gap_trunk(port[0], nearest) + # Keep the split/merge OUTSIDE the hub's framed group. When the hub icon + # lives inside a drawn box (e.g. EventBridge inside "Orchestration"), a + # trunk sitting just past the icon still bends WHILE inside the frame, so + # the fan visibly branches within an unrelated container. Push the trunk + # past the frame edge it exits through (plus a margin) so the bundle leaves + # the box as one line and only fans out beyond it — but never past the + # nearest spoke (that would drive the trunk into the targets). Only applies + # when the hub is a NODE enclosed by a framed group on the exit side. + if not hub_is_group and groups: + trunk_v = _push_trunk_outside_group( + trunk_v, side, vertical, nearest, + _enclosing_framed_group(groups, nodes, hub_id)) + # The spoke nodes (the N individual ends) must ALSO leave/enter through a # consistent edge — the one facing the trunk. A fan-in to a trunk BELOW the # agents means every agent exits its BOTTOM edge (not whichever side the From b6015d8158153cb70707a3550175d19ca226fd7f Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Tue, 30 Jun 2026 14:35:29 +0900 Subject: [PATCH 56/79] fix(layout): detect T-junction crossings + normalize detour paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two correctness fixes to crossing detection, surfacing real crossings the engine was silently missing (which in turn blocked legitimate straightening like S3 Lake → Consumers). 1. T-junction detection (_segments_intersect / _perp_touch): the perpendicular case required the meeting point to be STRICTLY interior to BOTH segments, so a T-junction (one segment's endpoint landing on the other's interior — e.g. an arrow ending on a line another arrow runs along) was reported as no-crossing. It now counts a meeting that is within both spans and interior to at least one, excluding only pure endpoint-to-endpoint port touches. 2. Fan-bundle exemption (_count_all_crossings / _is_fan_trunk_t_junction): widening detection above would make every fan spoke's peel-off T on the shared trunk self-report. Two edges locked onto the SAME fan bundle (same mode/axis/trunk/port) meeting ON the trunk line are the intended structure, not a crossing — skip them. A genuine interior×interior X (two spokes crossing mid-span) is still counted, and unrelated edges' T-junctions are always counted. 3. _normalize_path: detour splices could leave zero-length segments and redundant collinear vertices; strip them on commit so a stray stub can't fake a crossing and the path is minimal. Verified: agentic fan bundles stay at 0 (no false positives); data-lakehouse S3 Lake → Consumers now straightens (the real T with the etl→cw monitor line is correctly seen). ml_inference goes 3→11 crossings — NOT a regression: the drawing is byte-identical to before, the old count was undercounting two fan-in bundles sharing one trunk. No same-bundle false positives across demos. --- skill/sdpm/layout/__init__.py | 139 +++++++++++++++++++++++++++++++--- 1 file changed, 130 insertions(+), 9 deletions(-) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index f525bead..0841c3f3 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -2020,6 +2020,26 @@ def _count_port_crossings(conn_indices, port_indices, conn_sides, connections, n return crossings +def _perp_touch(v_x, h_y, h_x_min, h_x_max, v_y_min, v_y_max): + """True if a vertical seg (x=v_x, y in [v_y_min,v_y_max]) and a horizontal + seg (y=h_y, x in [h_x_min,h_x_max]) meet — counting T-junctions. + + The meeting point is (v_x, h_y). It must lie within BOTH segments' spans + (endpoints included), and be interior to AT LEAST ONE of them. The latter + excludes only a pure endpoint-to-endpoint touch (two stubs meeting at a + shared corner/port), which is not a visual crossing. A T-junction — where + one segment's endpoint lands in the middle of the other (e.g. an arrow + ending on a line another arrow runs along) — DOES count: the previous + strict-interior test silently dropped these, so two arrows sharing a y and + overlapping in x read as uncrossed when they visibly overlap. + """ + if not (h_x_min <= v_x <= h_x_max and v_y_min <= h_y <= v_y_max): + return False + v_interior = v_y_min < h_y < v_y_max + h_interior = h_x_min < v_x < h_x_max + return v_interior or h_interior + + def _segments_intersect(a1, a2, b1, b2): """Test if two axis-aligned segments cross or overlap (for port optimization).""" ax1, ay1 = a1 @@ -2037,13 +2057,13 @@ def _segments_intersect(a1, a2, b1, b2): h_x_min, h_x_max = min(ax1, ax2), max(ax1, ax2) v_x = bx1 v_y_min, v_y_max = min(by1, by2), max(by1, by2) - return h_x_min < v_x < h_x_max and v_y_min < h_y < v_y_max + return _perp_touch(v_x, h_y, h_x_min, h_x_max, v_y_min, v_y_max) if a_vert and b_horiz: v_x = ax1 v_y_min, v_y_max = min(ay1, ay2), max(ay1, ay2) h_y = by1 h_x_min, h_x_max = min(bx1, bx2), max(bx1, bx2) - return h_x_min < v_x < h_x_max and v_y_min < h_y < v_y_max + return _perp_touch(v_x, h_y, h_x_min, h_x_max, v_y_min, v_y_max) if a_horiz and b_horiz and ay1 == by1: a_min, a_max = min(ax1, ax2), max(ax1, ax2) b_min, b_max = min(bx1, bx2), max(bx1, bx2) @@ -2223,7 +2243,14 @@ def _count_all_crossings(edges): shared trunk — that overlap IS the merged bundle, not a crossing. So for such pairs we ignore collinear overlaps and only count a genuine perpendicular crossing. Unrelated edges still count overlaps (two separate - arrows drawn on the same line read as a defect).""" + arrows drawn on the same line read as a defect). + + Shared-endpoint pairs also produce a perpendicular T-junction where each + spoke peels off the shared trunk at its own port — the meeting point sits at + the spoke's true endpoint (pts[0] / pts[-1]). That T is the bundle's + intended structure, not a crossing, so it is skipped. A meeting that is + interior to BOTH polylines (a genuine 4-way X, e.g. two spokes crossing + mid-span) is always counted, even for a shared-endpoint pair.""" count = 0 for i in range(len(edges)): pts_i = edges[i]["points"] @@ -2244,16 +2271,71 @@ def _count_all_crossings(edges): for si in range(len(pts_i) - 1): for sj in range(len(pts_j) - 1): if _segments_intersect(pts_i[si], pts_i[si + 1], pts_j[sj], pts_j[sj + 1]): - # A shared-endpoint bundle's collinear overlap is the - # intended trunk, not a crossing. - if shares_endpoint and _segments_overlap_collinear( - pts_i[si], pts_i[si + 1], pts_j[sj], pts_j[sj + 1] - ): - continue + if shares_endpoint: + # A shared-endpoint bundle's collinear overlap is the + # intended trunk, not a crossing. + if _segments_overlap_collinear( + pts_i[si], pts_i[si + 1], pts_j[sj], pts_j[sj + 1] + ): + continue + # Two edges of the SAME fan bundle (same shared trunk) + # meet where each spoke peels off that trunk — a + # structural T-junction, not a crossing. Skip it, but + # only for the trunk-peel T: a genuine interior× + # interior X (two spokes truly crossing mid-span) is + # still counted. + if _is_fan_trunk_t_junction( + ei, ej, pts_i, si, pts_j, sj + ): + continue count += 1 return count +def _is_fan_trunk_t_junction(ei, ej, pts_i, si, pts_j, sj): + """True if two same-bundle fan edges meet at the shared trunk as a peel-off + T (structural), as opposed to a genuine 4-way crossing. + + Both edges must be `_fan_locked` onto the SAME bundle (same mode, axis, + trunk coordinate, and shared port). In that bundle the trunk is the line at + ``trunk`` on the bundle's axis; each spoke leaves the trunk perpendicular. + Their segments meet on the trunk line. That meeting is the intended shape, + UNLESS the meeting point is strictly interior to BOTH segments (two spokes + crossing away from the trunk), which is a real defect and returns False. + """ + la, lb = ei.get("_fan_locked"), ej.get("_fan_locked") + if not la or not lb: + return False + if (la["mode"] != lb["mode"] or la["axis"] != lb["axis"] + or la["trunk"] != lb["trunk"] or la["port"] != lb["port"]): + return False # different bundles → treat as unrelated, count normally + a1, a2 = pts_i[si], pts_i[si + 1] + b1, b2 = pts_j[sj], pts_j[sj + 1] + a_h, a_v = a1[1] == a2[1], a1[0] == a2[0] + b_h, b_v = b1[1] == b2[1], b1[0] == b2[0] + if a_h and b_v: + mx, my = b1[0], a1[1] + elif a_v and b_h: + mx, my = a1[0], b1[1] + else: + return False + # The meeting must lie on the bundle's trunk line; otherwise it is two + # spokes meeting away from the trunk (count it). + trunk = la["trunk"] + on_trunk = (mx == trunk) if la["axis"] == "x" else (my == trunk) + if not on_trunk: + return False + # A real 4-way X (interior to both segments) is a defect even on the trunk; + # only an endpoint-on-trunk peel-off is structural. + a_lo_x, a_hi_x = min(a1[0], a2[0]), max(a1[0], a2[0]) + a_lo_y, a_hi_y = min(a1[1], a2[1]), max(a1[1], a2[1]) + b_lo_x, b_hi_x = min(b1[0], b2[0]), max(b1[0], b2[0]) + b_lo_y, b_hi_y = min(b1[1], b2[1]), max(b1[1], b2[1]) + interior_a = a_lo_x < mx < a_hi_x or a_lo_y < my < a_hi_y + interior_b = b_lo_x < mx < b_hi_x or b_lo_y < my < b_hi_y + return not (interior_a and interior_b) + + # Negative inset = a keep-out margin AROUND each icon. A line running along or # just outside an icon's edge reads visually as touching/piercing it, so we # count it as a pierce and push it away. Kept small so legitimate adjacent @@ -2601,6 +2683,40 @@ def _is_axis_aligned(pts): ) +def _normalize_path(pts): + """Collapse a polyline's degenerate artifacts in place-safe form. + + Splicing jogs (and stacking several) can leave a path with: + - zero-length segments (consecutive identical points), and + - redundant collinear vertices (three points in a row on one axis), + which both read as a kink at a point that isn't really a corner and which + inflate the crossing count when a stray zero-length stub coincides with + another edge. This removes both without moving any real corner, so the + drawn shape is identical but minimal. Endpoints (pts[0], pts[-1]) are + preserved. Returns a new list; never shortens below 2 points. + """ + if len(pts) < 2: + return [list(p) for p in pts] + # 1) drop consecutive duplicates (zero-length segments) + dedup = [list(pts[0])] + for p in pts[1:]: + if p[0] != dedup[-1][0] or p[1] != dedup[-1][1]: + dedup.append(list(p)) + # 2) drop the middle of any three collinear points (same X or same Y run) + if len(dedup) <= 2: + return dedup + out = [dedup[0]] + for i in range(1, len(dedup) - 1): + a, b, c = out[-1], dedup[i], dedup[i + 1] + collinear_x = a[0] == b[0] == c[0] + collinear_y = a[1] == b[1] == c[1] + if collinear_x or collinear_y: + continue # b lies on the straight run a→c; skip it + out.append(b) + out.append(dedup[-1]) + return out + + def _entry_exit_ok(pts, src_side, dst_side): """First segment perpendicular to src edge, last to dst edge (no backwards). @@ -2927,6 +3043,11 @@ def cost(es): # position FIRST, then judge — mirrors evaluating a config # by its post-bend-optimization quality, not its raw form. cand = _optimize_jog_arm(cand, k, e, edges, nodes) + # Strip zero-length / collinear artifacts the splice (or + # a previously-committed jog stacked on this segment) may + # have left, so a stray stub can't fake a crossing and the + # committed shape is minimal. + cand = _normalize_path(cand) saved = e["points"] e["points"] = cand score = cost(edges) From a56a00c9177aa8b86088c8e3a5ee3d8e924c56e7 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Tue, 30 Jun 2026 15:56:39 +0900 Subject: [PATCH 57/79] feat(layout): U-turn group-endpoint monitor edges around framed boxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A monitor/aggregator edge from a GROUP box to a far node with framed groups in between (e.g. ETL group → CloudWatch across the Consumers frame) was left as a staircase by the detour pass — it still grazed the frame and pierced icons. New one-shot pass T16 (_uturn_group_endpoint_edges) tries a single clean U per qualifying edge: exit the group box's bottom (or top), run a trunk just past every framed box it would cross, and enter the far node on that same vertical face. Committed only if the weighted defect total strictly improves and crossings don't rise. Cheap by construction — O(edges × groups), one or two candidates per edge, no port/side search — so it adds no measurable time (data-lakehouse routes in 0.6s). data-lakehouse etl→cw is now a clean reverse-U (0c/0p/0gp); omnichannel also improves; all other demos unchanged. --- skill/sdpm/layout/__init__.py | 106 ++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 0841c3f3..6251c8be 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -1557,6 +1557,112 @@ def _layout_route_connections(connections, nodes, groups=None): # by the group bus (T14) are left alone. _straighten_group_edges(edges, nodes, groups) + # T16: U-turn a group-endpoint monitor edge around framed boxes. An edge + # from a GROUP box to a far node with framed groups in between (e.g. the ETL + # group → CloudWatch across the Consumers frame) defaults to a right-exit + # that the detour pass can only hack past, leaving a staircase that still + # grazes the frame. This one-shot pass tries a single clean U: exit the + # group's BOTTOM (or TOP), run a trunk just past the obstacle boxes, and + # enter the far node on the matching face. Committed only if it lowers the + # weighted defect total. Cheap: one candidate per qualifying edge. + _uturn_group_endpoint_edges(edges, nodes, groups) + + return edges + + +_UTURN_CLEAR = 20 + + +def _uturn_group_endpoint_edges(edges, nodes, groups): + """Reroute a solo group-endpoint edge that cuts framed boxes into a clean U. + + Targets an edge with a GROUP endpoint that still pierces one or more framed + group boxes (a monitor/aggregator line crossing the diagram). Builds ONE + candidate per vertical direction: leave the group box's bottom (or top) edge, + run a horizontal trunk just beyond ALL the boxes it would otherwise cross, + then rise/drop into the far endpoint on that same vertical face. Keeps the + candidate only if the global weighted defect total strictly improves and + crossings do not rise. O(edges × groups) — no port/side search. + """ + if not groups: + return edges + framed = [g for g in groups.values() if g.get("groupType")] + if not framed: + return edges + + def weighted(): + return _defect_weight((_count_all_crossings(edges), + _count_node_pierces(edges, nodes) + + _W_GROUP_PIERCE_ENGINE * _count_group_pierces(edges, groups, nodes), + _count_backwards(edges, nodes))) + + for e in edges: + if e.get("_fan_locked") or e.get("_fanout"): + continue + if len(e["points"]) < 2: + continue + # Must involve a group endpoint and still have a routing defect (a + # framed-box cut OR a non-endpoint icon pierce) the prior passes left — + # the staircase the detour produced still grazes icons/frames. + if not (e.get("_src_group") or e.get("_dst_group")): + continue + if (_count_group_pierces([e], groups, nodes) == 0 + and not _edge_pierces(e, nodes)): + continue + src, src_is_grp = _find_endpoint(nodes, groups, e["from"]) + dst, dst_is_grp = _find_endpoint(nodes, groups, e["to"]) + if not src or not dst: + continue + + # Boxes this edge must clear (framed, not its own endpoints/members). + efrom, eto = e["from"].rsplit(".", 1)[-1], e["to"].rsplit(".", 1)[-1] + boxes = [] + for gid, g in groups.items(): + if not g.get("groupType"): + continue + gshort = gid.rsplit(".", 1)[-1] + if gshort in (efrom, eto): + continue + members = _group_member_ids(nodes, groups, gid) + if efrom in members or eto in members: + continue + boxes.append(g) + if not boxes: + continue + + s_label = 0 if src_is_grp else (30 if src.get("label") else 0) + d_label = 0 if dst_is_grp else (30 if dst.get("label") else 0) + sx_c = src["x"] + src["width"] // 2 + dx_c = dst["x"] + dst["width"] // 2 + snap = [list(map(list, ee["points"])) for ee in edges] + before = weighted() + before_cross = _count_all_crossings(edges) + + best = None + for vside in ("bottom", "top"): + # Trunk Y just past every box on the chosen vertical side, and past + # both endpoints' own extents so the stubs don't clip their boxes. + if vside == "bottom": + trunk_y = max([g["y"] + g["height"] for g in boxes] + + [src["y"] + src["height"], dst["y"] + dst["height"]]) + _UTURN_CLEAR + sp = [sx_c, src["y"] + src["height"] + s_label] + tp = [dx_c, dst["y"] + dst["height"] + d_label] + else: + trunk_y = min([g["y"] for g in boxes] + + [src["y"], dst["y"]]) - _UTURN_CLEAR + sp = [sx_c, src["y"]] + tp = [dx_c, dst["y"]] + cand = [sp, [sp[0], trunk_y], [tp[0], trunk_y], tp] + e["points"] = cand + w = weighted() + if (w < before and _count_all_crossings(edges) <= before_cross + and (best is None or w < best[0])): + best = (w, [list(p) for p in cand]) + for ee, pts in zip(edges, snap): + ee["points"] = pts + + if best is not None: + e["points"] = best[1] return edges From 2fc6d688b1f5dcbc7cc9d168ca15b67bdd668dd6 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Tue, 30 Jun 2026 16:05:49 +0900 Subject: [PATCH 58/79] refactor(layout): routing warnings state facts, not prescriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crossing / node-pierce / group-frame-pierce warnings dropped their "Consider reordering / Restructure so… / Suggest: place X rightmost" prose. The LLM already has the logical JSON and the rendered image, so it can judge the right structural fix (group box, fan:merge, reorder, direction) itself — the prescriptions were verbose, sometimes mis-steering, and risked implying a fix the engine can't actually verify. Warnings now read e.g. "Edges A→B and C→D cross.", "Edge X→Y passes through node Z.", "Edge X→Y passes through group Z without connecting to it." Size/overflow warnings keep their short direction hint (mechanical, limited options). Guide's "Reading the output" updated to match. --- skill/references/guides/arch-layout-engine.md | 15 ++++++++----- skill/scripts/pptx_builder.py | 21 ++++--------------- 2 files changed, 14 insertions(+), 22 deletions(-) diff --git a/skill/references/guides/arch-layout-engine.md b/skill/references/guides/arch-layout-engine.md index bb7c6749..7c2efbd6 100644 --- a/skill/references/guides/arch-layout-engine.md +++ b/skill/references/guides/arch-layout-engine.md @@ -279,11 +279,16 @@ framed groups do. ## Reading the output -- `warnings`: human-readable hints (tall/wide group, label overlap, residual - crossings, **group-frame pierces** with restructuring suggestions). The - builder's edge-crossing warning is conservative and may flag a shared fan - trunk that is *not* a real crossing — trust the diagram and the QA metric over - that one warning. +- `warnings`: human-readable facts about what's wrong, NOT prescriptions. + Routing warnings state only the defect — `Edges A→B and C→D cross.`, + `Edge X→Y passes through node "Z".`, `Edge X→Y passes through group "Z" + without connecting to it.` — they deliberately do **not** tell you how to + fix it. You have the JSON and the rendered image; decide the structural fix + yourself (connect to a group box, `fan: "merge"`, reorder, change + `direction`, …) using the techniques above. Size warnings (tall/wide group, + overflow, compressed spacing) still carry a short hint. The builder's + edge-crossing warning is conservative and may flag a shared fan trunk that is + *not* a real crossing — trust the diagram and the QA metric over that one. - `bbox`: final bounding box after scale-to-fit. For an objective check, the QA harness reports crossings / pierces / diff --git a/skill/scripts/pptx_builder.py b/skill/scripts/pptx_builder.py index 4d08f1ef..bee8dec7 100644 --- a/skill/scripts/pptx_builder.py +++ b/skill/scripts/pptx_builder.py @@ -750,16 +750,7 @@ def build_root(): continue nx, ny, nw, nh = n["x"], n["y"], n["width"], n["height"] if seg_max_x > nx + margin and seg_min_x < nx + nw - margin and seg_max_y > ny + margin and seg_min_y < ny + nh - margin: - # Suggest reordering based on connection direction - suggest = "" - dst_node = nodes_out.get(dst_id) or next((v for k, v in nodes_out.items() if k.endswith(dst_id)), None) - if dst_node: - src_node = nodes_out.get(src_id) or next((v for k, v in nodes_out.items() if k.endswith(src_id)), None) - if src_node: - dx = dst_node["x"] - src_node["x"] - direction = "rightmost" if dx > 0 else "leftmost" - suggest = f' Suggest: place "{src_node.get("label", src_id)}" {direction} in its group, adjacent to "{dst_node.get("label", dst_id)}".' - warnings.append(f'Edge {edge_key} crosses node "{n.get("label", nid)}". Reorder nodes so connected elements are adjacent, or group branch targets in the perpendicular direction. Also consider reverse: true on the target group if connections flow opposite to layout direction.{suggest}') + warnings.append(f'Edge {edge_key} passes through node "{n.get("label", nid)}".') crossing_reported.add(report_key) # Check edge-edge crossings (segment intersection) @@ -782,7 +773,7 @@ def build_root(): if key_ij not in edge_crossing_reported: e_i = f"{edges_out[i]['from']}→{edges_out[i]['to']}" e_j = f"{edges_out[j]['from']}→{edges_out[j]['to']}" - warnings.append(f"Edges {e_i} and {e_j} cross each other. Consider reordering nodes or restructuring groups to eliminate the crossing.") + warnings.append(f"Edges {e_i} and {e_j} cross.") edge_crossing_reported.add(key_ij) crossed = True break @@ -816,12 +807,8 @@ def build_root(): for k in range(len(pts) - 1)): glabel = g.get("label", gshort) warnings.append( - f'Edge {e["from"]}→{e["to"]} cuts through group "{glabel}" ' - f'without connecting to it. Restructure so the line does not ' - f'cross an unrelated container: reorder groups so the endpoints ' - f'are adjacent (no group between them), move "{glabel}" off the ' - f'path, or connect to the group box itself (many-to-one) if the ' - f'flow really does enter it.') + f'Edge {e["from"]}→{e["to"]} passes through group ' + f'"{glabel}" without connecting to it.') gframe_reported.add(key) # Structure suggestions: sibling size imbalance From 946ed8a6429e27cb46535c50210165051563e838 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Tue, 30 Jun 2026 16:11:21 +0900 Subject: [PATCH 59/79] docs(layout): add anti-pattern for one-source-set fanning to two targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ml_inference surfaced this: three workers each writing to both a feature store and a prediction stream put two fan-in bundles on one shared trunk, so every spoke crosses the other bundle (11 crossings). Document it as an anti-pattern with the fix — group the two targets and fan into the box, or give each target its own row. --- skill/references/guides/arch-layout-engine.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/skill/references/guides/arch-layout-engine.md b/skill/references/guides/arch-layout-engine.md index 7c2efbd6..4b5aabd2 100644 --- a/skill/references/guides/arch-layout-engine.md +++ b/skill/references/guides/arch-layout-engine.md @@ -247,6 +247,12 @@ element). If you need the cluster named, keep a `groupType`. spacing; flip the root to horizontal. - **Putting a degree-1 helper inline on the flow** and expecting no pierce → wrap it perpendicular. +- **One source set fanning into TWO separate targets** (e.g. three workers each + writing to both a *feature store* and a *prediction stream*) → the two + fan-in bundles overlap on one shared trunk and every spoke crosses the other + bundle. Don't aim a many-source fan at two scattered targets. Instead put the + two targets in ONE group and fan into that group box (technique 1), or give + each target its own row so the two bundles don't share a trunk lane. - **Routing a line past a framed group that sits across its path** → the line cuts through an unrelated container's box (a *group-frame pierce*). See below. From 7da1a854ee30d35b09c2efdc648cddbad28d6d13 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Wed, 1 Jul 2026 16:50:31 +0900 Subject: [PATCH 60/79] perf+fix(layout): faster routing + order-model straightens group-endpoint arrows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent improvements to the ordering/routing engine: 1. Routing speed (bbox pruning). _count_all_crossings and _count_node_pierces now reject far-apart edge/edge and edge/node pairs by bounding box before the O(segments²) segment test, and precompute each node's short id + pierce box once instead of per-pair. microservices' single full route drops from ~10.8s to ~2.1s; results are byte-identical (pure speedup). 2. Order model straightens a lone group-endpoint arrow. The crossing-order search now breaks ties by a peer-detour cost: when EXACTLY ONE direct child of a group connects outside it, seat that child at the row end facing its peer so its arrow runs straight instead of detouring around a sibling icon (the multi-region-DR "API container → Data tier" case, now a straight line). Group ids are given positions for this tie-break ONLY (via include_groups), so many-to-one edges to a sibling box are visible; the crossing count itself stays leaf-only to avoid reshuffling unrelated diagrams. Gated to the single-connected-child case so multi-way groups are left to the crossing model. All 11 demo diagrams keep identical crossings/pierces/group_pierces; only the DR diagram changes, for the better. optimize_order stays ~1ms (no per-order re-routing — an earlier full-routing-eval version was reverted as too slow). --- skill/sdpm/layout/__init__.py | 165 +++++++++++++++++++++++++++++++--- 1 file changed, 154 insertions(+), 11 deletions(-) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 6251c8be..3759da02 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -25,6 +25,10 @@ def optimize_order(tree): # column the LLM wrote directly) with their flow anchor, so the same # anchor-on-flow alignment applies as for engine-promoted lanes. _tag_manual_branch_anchors(tree, connections) + # Order children within each group to minimize crossings. Uses a routed- + # quality model that accounts not just for leaf-vs-leaf crossings but also + # for an edge to a sibling GROUP box having to detour around a nearer child + # (see _count_crossings_for_mixed_order's peer-adjacency term). _optimize_group_order(tree, connections) @@ -274,11 +278,18 @@ def _find_min_crossing_order_mixed(children, relevant, all_connections, internal """ from itertools import permutations - best_crossings = None + best_key = None best_perm = None - # Build a global position map from the full tree (excluding this group's leaves) - global_positions = _compute_global_peer_positions(children, all_connections, child_leaf_ids, root) + # Two position maps from the full tree: + # - leaf-only, for the crossing count (unchanged legacy behaviour — adding + # group ids here would reclassify group-endpoint edges and shuffle orders + # on unrelated diagrams like omnichannel). + # - group-inclusive, for the detour tie-break ONLY, so a many-to-one edge + # to a sibling GROUP box is visible when seating its single connected + # child (the DR diagram's API → Data tier). + leaf_positions = _compute_global_peer_positions(children, all_connections, child_leaf_ids, root) + group_positions = _compute_global_peer_positions(children, all_connections, child_leaf_ids, root, include_groups=True) for perm in permutations(children): perm_ids = [c["id"] for c in perm] @@ -294,22 +305,98 @@ def _find_min_crossing_order_mixed(children, relevant, all_connections, internal if not valid: continue - crossings = _count_crossings_for_mixed_order(perm, relevant, global_positions, child_leaf_ids) - if best_crossings is None or crossings < best_crossings: - best_crossings = crossings + crossings = _count_crossings_for_mixed_order(perm, relevant, leaf_positions, child_leaf_ids) + # Tie-break: among equal-crossing orders prefer the one where each child + # that connects to an OUTSIDE peer sits at the end of the row facing that + # peer. Otherwise a lone edge to a sibling group (e.g. an API container → + # the Data tier) leaves author order and detours around the outer + # sibling. This is a pure secondary key — it can never pick an order with + # more crossings — so it can't regress a diagram that ordering already + # solved; it only breaks ties the crossing count leaves open. + detour = _peer_detour_cost(perm, relevant, group_positions, child_leaf_ids) + key = (crossings, detour) + if best_key is None or key < best_key: + best_key = key best_perm = list(perm) - if crossings == 0: + if crossings == 0 and detour == 0: break return best_perm -def _compute_global_peer_positions(children, all_connections, child_leaf_ids, root): +def _peer_detour_cost(perm, relevant, global_positions, child_leaf_ids): + """Secondary ordering key: how far each externally-connected child sits from + the row end facing its outside peer. + + For every edge between an internal leaf and an external peer (leaf OR group + box), the internal endpoint ideally sits at the row end nearest that peer — + the right end if the peer is to the right (higher global position than this + row's own centre), the left end if to the left. The cost sums the slot + distance from that ideal end; 0 when every connected child already hugs the + correct end. The datum is THIS row's mean peer-space position (not a global + average), so left/right is judged relative to the row itself — the fix for + the earlier version that flipped sides between otherwise-identical rows. + """ + n = len(perm) + # Slot of each internal leaf under this permutation, and per-child leaf sets. + leaf_slot = {} + internal = set() + child_leaf_sets = [] + for slot, child in enumerate(perm): + cl = set(child_leaf_ids.get(child["id"], [child["id"]])) + child_leaf_sets.append(cl) + for lid in cl: + leaf_slot[lid] = slot + internal.add(lid) + + # Only apply this tie-break when EXACTLY ONE direct child connects outside + # the group. That is the unambiguous "seat the one connected child at the + # peer-facing end" case (the DR diagram's API container). When several + # children connect outside, where each should sit is a multi-way trade the + # crossing model already handles; forcing one toward a peer end there just + # shuffles the row and can push another edge through a frame (the + # omnichannel services regression). Return 0 = no tie-break preference. + connected_children = 0 + for cl in child_leaf_sets: + if any((cn["from"] in cl and cn["to"] not in internal) + or (cn["to"] in cl and cn["from"] not in internal) + for cn in relevant): + connected_children += 1 + if connected_children != 1: + return 0 + + # This row's own centre in global peer-space: mean global position of the + # external peers it connects to (so "left/right" is relative to the row). + peer_positions = [] + for conn in relevant: + for a, b in ((conn["from"], conn["to"]), (conn["to"], conn["from"])): + if a in internal and b in global_positions and b not in internal: + peer_positions.append(global_positions[b]) + if not peer_positions: + return 0 + datum = sum(peer_positions) / len(peer_positions) + cost = 0 + for conn in relevant: + for a, b in ((conn["from"], conn["to"]), (conn["to"], conn["from"])): + if a in leaf_slot and b in global_positions and b not in internal: + ideal = (n - 1) if global_positions[b] >= datum else 0 + cost += abs(leaf_slot[a] - ideal) + return cost + + +def _compute_global_peer_positions(children, all_connections, child_leaf_ids, root, include_groups=False): """Compute normalized positions for external peers. External peers are nodes NOT contained in any of the children being permuted. Their position is based on DFS order of leaf nodes in the root tree, normalized to a [0, N] range where N is the number of internal leaf slots. + + ``include_groups`` also registers GROUP ids at the mean position of their + member leaves. This is used ONLY by the detour tie-break (so a many-to-one + edge to a sibling group box is visible when seating the single connected + child). The crossing count deliberately uses the leaf-only map — adding + groups there reclassifies group-endpoint edges and shuffles unrelated + diagrams' orders. """ # All leaves within this group all_internal = set() @@ -326,7 +413,24 @@ def _compute_global_peer_positions(children, all_connections, child_leaf_ids, ro return {} # Assign sequential positions to external leaves - return {lid: i for i, lid in enumerate(external_leaves)} + pos = {lid: i for i, lid in enumerate(external_leaves)} + + if include_groups: + # Position external GROUP ids at the mean position of their members so a + # connection targeting a group BOX (e.g. an API container → the Data + # tier) is visible to the detour tie-break. + def _register(node): + ml = [] + _collect_leaf_ids(node, ml) + gid = node.get("id") + if gid is not None and node.get("children"): + ext = [pos[m] for m in ml if m in pos] + if ext and not any(m in all_internal for m in ml): + pos[gid] = sum(ext) / len(ext) + for ch in node.get("children", []): + _register(ch) + _register(root) + return pos def _count_crossings_for_mixed_order(perm, relevant, global_positions, child_leaf_ids): @@ -2357,16 +2461,35 @@ def _count_all_crossings(edges): intended structure, not a crossing, so it is skipped. A meeting that is interior to BOTH polylines (a genuine 4-way X, e.g. two spokes crossing mid-span) is always counted, even for a shared-endpoint pair.""" + # Pre-compute each edge's bounding box once; two edges whose boxes don't + # overlap can't cross, so we skip the O(segments²) inner test entirely. + # This is the hot path (called thousands of times by the bend/side/detour + # optimizers), so the cheap box reject saves the bulk of the work. + boxes = [] + for e in edges: + pts = e["points"] + if len(pts) < 2: + boxes.append(None) + continue + xs = [p[0] for p in pts] + ys = [p[1] for p in pts] + boxes.append((min(xs), min(ys), max(xs), max(ys))) + count = 0 for i in range(len(edges)): pts_i = edges[i]["points"] if len(pts_i) < 2: continue ei = edges[i] + bi = boxes[i] for j in range(i + 1, len(edges)): pts_j = edges[j]["points"] if len(pts_j) < 2: continue + bj = boxes[j] + # Bounding-box reject: no overlap → no crossing. + if bi[0] > bj[2] or bj[0] > bi[2] or bi[1] > bj[3] or bj[1] > bi[3]: + continue ej = edges[j] shares_endpoint = ( ei.get("from") == ej.get("from") @@ -2472,16 +2595,36 @@ def _seg_pierces_node(p1, p2, n): def _count_node_pierces(edges, nodes): """Count (edge, node) pairs where an edge passes through a non-endpoint icon.""" + # Pre-compute each node's short id and its expanded pierce box ONCE (this is + # a hot path called thousands of times by the optimizers). The old code + # recomputed nid.rsplit and the box for every (edge, node) pair — millions + # of times on a dense diagram. + node_info = [] + for nid, n in nodes.items(): + short = nid.rsplit(".", 1)[-1] + rx, ry = n["x"], n["y"] + rw = n.get("width", 60) + rh = n.get("height", rw) + x0, y0 = rx + _PIERCE_INSET, ry + _PIERCE_INSET + x1, y1 = rx + rw - _PIERCE_INSET, ry + rh - _PIERCE_INSET + node_info.append((nid, short, n, x0, y0, x1, y1)) + count = 0 for e in edges: pts = e["points"] if len(pts) < 2: continue ignore = {e["from"], e["to"]} - for nid, n in nodes.items(): - short = nid.rsplit(".", 1)[-1] + # Edge bounding box for a cheap reject against each node's pierce box. + exs = [p[0] for p in pts] + eys = [p[1] for p in pts] + emnx, emny, emxx, emxy = min(exs), min(eys), max(exs), max(eys) + for nid, short, n, x0, y0, x1, y1 in node_info: if nid in ignore or short in ignore: continue + # Box reject: edge bbox vs node's expanded pierce box. + if emnx > x1 or x0 > emxx or emny > y1 or y0 > emxy: + continue for k in range(len(pts) - 1): if _seg_pierces_node(pts[k], pts[k + 1], n): count += 1 From 29c23be73a4701b2dde76528c064b43a9375555e Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Wed, 8 Jul 2026 17:51:05 +0900 Subject: [PATCH 61/79] refactor(layout): extract canonical render pipeline into sdpm.layout Extract cmd_layout's inline pipeline into reusable pure functions so the CLI, the QA harness, and (future) MCP tools all call one source of truth instead of re-implementing scale/route/measure. - Add sdpm/layout/render.py: build_layout() + render_architecture() -> {elements, bbox, warnings, metrics}. Respects the root `reverse` flag. - Add sdpm/layout/metrics.py: measure/measure_layout/score, moved from layout_qa.py. - Move _segments_cross into sdpm.layout (builder re-exports it). - cmd_layout is now a thin wrapper (include_metrics=False keeps stdout byte-identical to prior versions). - layout_qa.py drops its duplicated _build_layout and delegates to the canonical pipeline; still re-exports measure/score for layout_search.py. Fixes a latent bug: layout_qa's duplicate pipeline ignored `reverse`, so QA metrics disagreed with what the CLI actually drew for reversed diagrams. Verified against HEAD across 7 diverse diagrams: CLI stdout byte-identical; QA metrics identical except the one reversed diagram (wire_norm/score now reflect true geometry). ruff clean, 188 tests pass. --- skill/scripts/layout_qa.py | 284 +------------------- skill/scripts/pptx_builder.py | 472 ++------------------------------ skill/sdpm/layout/__init__.py | 53 ++++ skill/sdpm/layout/metrics.py | 250 +++++++++++++++++ skill/sdpm/layout/render.py | 490 ++++++++++++++++++++++++++++++++++ 5 files changed, 824 insertions(+), 725 deletions(-) create mode 100644 skill/sdpm/layout/metrics.py create mode 100644 skill/sdpm/layout/render.py diff --git a/skill/scripts/layout_qa.py b/skill/scripts/layout_qa.py index 0bb24ac5..93781159 100644 --- a/skill/scripts/layout_qa.py +++ b/skill/scripts/layout_qa.py @@ -2,298 +2,36 @@ # SPDX-License-Identifier: MIT-0 """Layout QA harness: objective quality metrics for the layout engine. -Runs the REAL engine pipeline (same as pptx_builder.cmd_layout) on a logical -structure JSON, then measures geometric quality: +Runs the REAL engine pipeline (``sdpm.layout.render.build_layout``, the same +one used by ``pptx_builder.py layout``) on a logical structure JSON, then +measures geometric quality: - crossings : pairs of edge segments that intersect - pierces : edge segments passing through a non-endpoint node icon - diagonals : segments that are neither horizontal nor vertical - bad_ports : first/last segment not perpendicular to its node edge - backwards : first segment travels opposite to the port's outward normal +The pipeline and the metric definitions live in the ``sdpm.layout`` package so +this harness and the CLI can never drift apart. This file is a thin CLI shim. + Usage: python3 layout_qa.py [--width W] [--height H] [--json] """ import argparse -import copy import json import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -from sdpm.layout import ( # noqa: E402 - optimize_order, - _layout_scale, - _layout_translate, - _layout_collect, - _layout_route_connections, - _count_all_crossings, - _count_group_pierces, - _seg_pierces_node, - _find_node, - cancel_cross_axis_squash, - measure_natural_child_sizes, +# Re-exported for backward compatibility (e.g. layout_search.py imports these). +from sdpm.layout.metrics import ( # noqa: E402,F401 + measure, + measure_layout, + score, ) -_TOL = 2 - - -def _build_layout(tree, target_w=None, target_h=None): - """Replicate cmd_layout's scale/translate/collect/route pipeline.""" - direction = tree.get("direction", "horizontal") - align = tree.get("align", "center") - optimize_order(tree) - - def build_root(): - return { - "id": "_root", - "children": copy.deepcopy(tree.get("children", tree.get("nodes", []))), - "direction": direction, - "align": align, - } - - root = build_root() - _layout_scale(root, direction, align) - natural_sizes = measure_natural_child_sizes(tree, root) - cum_h = cum_v = 1.0 - if target_w or target_h: - for _ in range(10): - rb = root["_bindings"] - sx = target_w / rb[2] if target_w else 1.0 - sy = target_h / rb[3] if target_h else 1.0 - if abs(sx - 1.0) < 0.03 and abs(sy - 1.0) < 0.03: - break - cum_h *= sx - cum_v *= sy - root = build_root() - _layout_scale(root, direction, align, cum_h, cum_v) - # Cancel cross-axis squash on groups that fit (mirror cmd_layout). - if cancel_cross_axis_squash(tree, natural_sizes, cum_h, cum_v, - target_w, target_h): - root = build_root() - _layout_scale(root, direction, align, cum_h, cum_v) - - rb = root["_bindings"] - _layout_translate(root, -rb[0], -rb[1]) - nodes, groups = {}, {} - for child in root["children"]: - _layout_collect(child, nodes, groups) - edges = _layout_route_connections(tree.get("connections", []), nodes, groups) - return nodes, groups, edges, root["_bindings"] - - -def _seg_pierces_rect(p1, p2, n): - # Use the engine's own pierce definition so the QA metric and the - # optimizer's cost function agree on what counts as a pierce/graze. - return _seg_pierces_node(p1, p2, n) - - -def _side_of(node, pt): - """Classify which edge a port point sits on. - - Ports on the bottom edge are offset downward by the label height, so a - point below the icon that is horizontally inside the icon's x-span is a - bottom port (not a left/right one). Likewise points above are top ports. - """ - x, y = pt - cx, cy = node["x"], node["y"] - w = node.get("width", 60) - h = node.get("height", w) - # Inside the icon's horizontal span and at/below or at/above → top/bottom port - if cx - _TOL <= x <= cx + w + _TOL: - if y >= cy + h - _TOL: - return "bottom" - if y <= cy + _TOL: - return "top" - # Inside the icon's vertical span → left/right port - if cy - _TOL <= y <= cy + h + _TOL: - if x <= cx + _TOL: - return "left" - if x >= cx + w - _TOL: - return "right" - # Fallback: nearest edge - d = { - "left": abs(x - cx), - "right": abs(x - (cx + w)), - "top": abs(y - cy), - "bottom": abs(y - (cy + h)), - } - return min(d, key=d.get) - - -def measure(tree, target_w=1720, target_h=800): - nodes, groups, edges, rb = _build_layout(tree, target_w, target_h) - - crossings = _count_all_crossings(edges) - group_pierces = _count_group_pierces(edges, groups, nodes) - - pierces = [] - diagonals = [] - bad_ports = [] - backwards = [] - wirelength = 0.0 - - for e in edges: - pts = e["points"] - if len(pts) < 2: - continue - - # total wire length (paths are axis-aligned, so Manhattan == polyline - # length). Minimizing this clusters connected icons together. - for k in range(len(pts) - 1): - wirelength += abs(pts[k][0] - pts[k + 1][0]) + abs(pts[k][1] - pts[k + 1][1]) - ig = {e["from"], e["to"]} - src = _find_node(nodes, e["from"]) - dst = _find_node(nodes, e["to"]) - - # pierces - for nid, n in nodes.items(): - short = nid.rsplit(".", 1)[-1] - if short in ig or nid in ig: - continue - for k in range(len(pts) - 1): - if _seg_pierces_rect(pts[k], pts[k + 1], n): - pierces.append((f"{e['from'].rsplit('.',1)[-1]}->{e['to'].rsplit('.',1)[-1]}", short)) - break - - # diagonals - for k in range(len(pts) - 1): - dx = abs(pts[k][0] - pts[k + 1][0]) - dy = abs(pts[k][1] - pts[k + 1][1]) - if dx > _TOL and dy > _TOL: - diagonals.append((f"{e['from'].rsplit('.',1)[-1]}->{e['to'].rsplit('.',1)[-1]}", pts[k], pts[k + 1])) - - # port perpendicularity + backwards (skip detours which legitimately exit bottom) - if src is not None: - ss = _side_of(src, pts[0]) - seg = (pts[0], pts[1]) - horiz = abs(seg[0][1] - seg[1][1]) <= _TOL - vert = abs(seg[0][0] - seg[1][0]) <= _TOL - exp_horiz = ss in ("left", "right") - if exp_horiz and not horiz: - bad_ports.append((f"src {e['from'].rsplit('.',1)[-1]}", ss, "not-horizontal")) - if (not exp_horiz) and not vert: - bad_ports.append((f"src {e['from'].rsplit('.',1)[-1]}", ss, "not-vertical")) - # backwards: exits right but first seg goes left, etc. - if ss == "right" and seg[1][0] < seg[0][0] - _TOL: - backwards.append(f"{e['from'].rsplit('.',1)[-1]}[right]->{e['to'].rsplit('.',1)[-1]}") - if ss == "left" and seg[1][0] > seg[0][0] + _TOL: - backwards.append(f"{e['from'].rsplit('.',1)[-1]}[left]->{e['to'].rsplit('.',1)[-1]}") - if ss == "bottom" and seg[1][1] < seg[0][1] - _TOL: - backwards.append(f"{e['from'].rsplit('.',1)[-1]}[bottom]->{e['to'].rsplit('.',1)[-1]}") - if ss == "top" and seg[1][1] > seg[0][1] + _TOL: - backwards.append(f"{e['from'].rsplit('.',1)[-1]}[top]->{e['to'].rsplit('.',1)[-1]}") - - if dst is not None: - ds = _side_of(dst, pts[-1]) - seg = (pts[-2], pts[-1]) - horiz = abs(seg[0][1] - seg[1][1]) <= _TOL - vert = abs(seg[0][0] - seg[1][0]) <= _TOL - exp_horiz = ds in ("left", "right") - if exp_horiz and not horiz: - bad_ports.append((f"dst {e['to'].rsplit('.',1)[-1]}", ds, "not-horizontal")) - if (not exp_horiz) and not vert: - bad_ports.append((f"dst {e['to'].rsplit('.',1)[-1]}", ds, "not-vertical")) - - w, h = rb[2], rb[3] - diag = (w * w + h * h) ** 0.5 or 1.0 - # normalize wirelength by the canvas diagonal so the soft term is - # comparable across differently-sized candidate layouts. - wire_norm = wirelength / diag - aspect = (w / h) if h else 0.0 - - # Overflow: how far the laid-out bounds exceed the slide frame after - # scale-to-fit. A tall (vertical-root) layout can stop shrinking once its - # icons+labels hit their minimum size, leaving height > target_h — those - # icons render off-slide. This is unusable regardless of crossing count, - # so it ranks ABOVE crossings in the score. Expressed as a fraction of - # the target dimension (0 == fits). - overflow = 0.0 - if target_w and w > target_w: - overflow += (w - target_w) / target_w - if target_h and h > target_h: - overflow += (h - target_h) / target_h - - result = { - "crossings": crossings, - "pierces": len(pierces), - "group_pierces": group_pierces, - "diagonals": len(diagonals), - "bad_ports": len(bad_ports), - "backwards": len(backwards), - "overflow": round(overflow, 3), - "wirelength": round(wirelength), - "wire_norm": round(wire_norm, 3), - "aspect": round(aspect, 3), - "size": [round(w), round(h)], - "detail": { - "pierces": pierces, - "diagonals": diagonals, - "bad_ports": bad_ports, - "backwards": backwards, - }, - } - result["score"] = score(result) - return result - - -# Aspect ratio considered visually comfortable for a 16:9 slide body. -_ASPECT_LO, _ASPECT_HI = 1.4, 3.2 - -# Geometric-defect weights, combined into ONE additive layer so the search -# can't trade one defect class for a worse total of another (e.g. drive -# crossings to 0 by introducing 12 pierces). A pierce (arrow through a -# non-endpoint icon) reads worse than a crossing, so it is weighted heavier; -# a backwards segment is a softer wrongness than either. -_W_CROSS = 1.0 -_W_PIERCE = 1.5 -_W_GROUP_PIERCE = 1.0 -_W_BACK = 0.7 -_W_BADPORT = 0.5 - - -def score(m): - """Multi-objective score; lower is better. - - Two layers, compared lexicographically: - 1. overflow — does the layout spill off the slide frame? Off-slide - icons are unusable regardless of routing quality, so any - real overflow outranks everything below. - 2. defects — a single WEIGHTED SUM of geometric defects (crossings, - pierces, backwards, bad ports/diagonals). Combining them - additively (rather than as separate lexicographic tiers) - prevents the degenerate trade where the search zeroes one - defect class by inflating another. - Soft aesthetic terms (wire length, aspect penalty) break ties within the - defect layer. This is the "judge" that picks which position-shifted - candidate is actually good. - """ - aspect = m["aspect"] - if aspect < _ASPECT_LO: - aspect_pen = _ASPECT_LO - aspect - elif aspect > _ASPECT_HI: - aspect_pen = aspect - _ASPECT_HI - else: - aspect_pen = 0.0 - soft = m["wire_norm"] + 2.0 * aspect_pen - # Overflow bucketed to 0.1 (10% of a slide dimension) so small rounding - # jitter doesn't reorder otherwise-equal layouts, but any real off-slide - # spill outranks every geometric defect below it. - overflow_bucket = round(m.get("overflow", 0.0) * 10) - defects = ( - _W_CROSS * m["crossings"] - + _W_PIERCE * m["pierces"] - + _W_GROUP_PIERCE * m.get("group_pierces", 0) - + _W_BACK * m["backwards"] - + _W_BADPORT * (m["bad_ports"] + m["diagonals"]) - ) - return ( - overflow_bucket, - round(defects, 2), - round(soft, 3), - ) - def main(): ap = argparse.ArgumentParser() diff --git a/skill/scripts/pptx_builder.py b/skill/scripts/pptx_builder.py index bee8dec7..1eb0f281 100644 --- a/skill/scripts/pptx_builder.py +++ b/skill/scripts/pptx_builder.py @@ -34,18 +34,20 @@ load_slides_json_or_pptx, match_elements, ) -from sdpm.layout import ( +from sdpm.layout import ( # noqa: F401 _group_member_ids, _layout_collect, _layout_route_connections, _layout_scale, _layout_translate, _seg_crosses_box, + _segments_cross, box_to_elements, cancel_cross_axis_squash, measure_natural_child_sizes, optimize_order, ) +from sdpm.layout.render import render_architecture from sdpm.preview.backend import _is_wsl from sdpm.utils.effects import apply_effects # noqa: F401 from sdpm.utils.image import apply_image_effects, resolve_image_path # noqa: F401 @@ -392,56 +394,14 @@ def cmd_code_block(args): print(out_str) -def _segments_cross(a1, a2, b1, b2): - """Test if two axis-aligned line segments (a1-a2) and (b1-b2) cross or overlap. +def cmd_layout(args): + """Layout engine: compute coordinates from logical structure JSON. - Detects: - 1. Perpendicular crossings (one horizontal, one vertical) - 2. Collinear overlap (parallel segments sharing the same axis with overlapping range) + Thin CLI wrapper around ``sdpm.layout.render.render_architecture`` (the + canonical pipeline). CLI output intentionally omits the ``metrics`` key so + stdout stays byte-compatible with prior versions; use ``layout_qa.py`` for + metrics. """ - ax1, ay1 = a1 - ax2, ay2 = a2 - bx1, by1 = b1 - bx2, by2 = b2 - - a_horiz = ay1 == ay2 - a_vert = ax1 == ax2 - b_horiz = by1 == by2 - b_vert = bx1 == bx2 - - # Perpendicular crossings - if a_horiz and b_vert: - h_y = ay1 - h_x_min, h_x_max = min(ax1, ax2), max(ax1, ax2) - v_x = bx1 - v_y_min, v_y_max = min(by1, by2), max(by1, by2) - return h_x_min < v_x < h_x_max and v_y_min < h_y < v_y_max - if a_vert and b_horiz: - v_x = ax1 - v_y_min, v_y_max = min(ay1, ay2), max(ay1, ay2) - h_y = by1 - h_x_min, h_x_max = min(bx1, bx2), max(bx1, bx2) - return h_x_min < v_x < h_x_max and v_y_min < h_y < v_y_max - - # Collinear overlap: both horizontal on same Y - if a_horiz and b_horiz and ay1 == by1: - a_min, a_max = min(ax1, ax2), max(ax1, ax2) - b_min, b_max = min(bx1, bx2), max(bx1, bx2) - overlap = min(a_max, b_max) - max(a_min, b_min) - return overlap > 5 - - # Collinear overlap: both vertical on same X - if a_vert and b_vert and ax1 == bx1: - a_min, a_max = min(ay1, ay2), max(ay1, ay2) - b_min, b_max = min(by1, by2), max(by1, by2) - overlap = min(a_max, b_max) - max(a_min, b_min) - return overlap > 5 - - return False - - -def cmd_layout(args): - """Layout engine: compute coordinates from logical structure JSON.""" if args.input == "-": import sys as _sys source = _sys.stdin.read() @@ -449,409 +409,19 @@ def cmd_layout(args): source = Path(args.input).read_text(encoding="utf-8") tree = json.loads(source) - direction = tree.get("direction", "horizontal") - align = tree.get("align", "center") - reverse = tree.get("reverse", False) - - # Allow targetArea in JSON to override CLI args - target_area = tree.get("targetArea", {}) - if target_area: - if "x" in target_area and not args.x: - args.x = target_area["x"] - if "y" in target_area and not args.y: - args.y = target_area["y"] - if "width" in target_area and not args.width: - args.width = target_area["width"] - if "height" in target_area and not args.height: - args.height = target_area["height"] - - # Pre-process: optimize node order within groups to minimize edge crossings - optimize_order(tree) - - def build_root(): - import copy - root = {"id": "_root", "children": copy.deepcopy(tree.get("children", tree.get("nodes", []))), - "direction": direction, "align": align} - if reverse: - root["reverse"] = True - return root - - # Pass 1: natural size - root = build_root() - _layout_scale(root, direction, align) - rb = root["_bindings"] - _, _ = rb[2], rb[3] - - target_w = args.width - target_h = args.height - - # Record each top-level group's NATURAL cross-axis size so we can later tell - # which groups fit on their own (before any global squash). - natural_sizes = measure_natural_child_sizes(tree, root) - - cumulative_sh = 1.0 - cumulative_sv = 1.0 - if target_w or target_h: - for _ in range(8): - rb = root["_bindings"] - sx = target_w / rb[2] if target_w else 1.0 - sy = target_h / rb[3] if target_h else 1.0 - if abs(sx - 1.0) < 0.03 and abs(sy - 1.0) < 0.03: - break - cumulative_sh *= sx - cumulative_sv *= sy - root = build_root() - _layout_scale(root, direction, align, cumulative_sh, cumulative_sv) - # A single oversized group forces a global cross-axis squash that would - # crush short siblings. Cancel that squash on the groups that already - # fit (the slide may overflow — that's the oversized group's problem, - # flagged by a warning — but the groups that fit stay readable). - if cancel_cross_axis_squash(tree, natural_sizes, cumulative_sh, - cumulative_sv, target_w, target_h): - root = build_root() - _layout_scale(root, direction, align, cumulative_sh, cumulative_sv) - args._cumulative_scale = min(cumulative_sh, cumulative_sv) - - rb = root["_bindings"] - ox = (args.x or 0) - rb[0] - oy = (args.y or 0) - rb[1] - _layout_translate(root, ox, oy) - - # Center if still slightly off from target - rb = root["_bindings"] - if target_w and abs(rb[2] - target_w) > 5: - dx = (target_w - rb[2]) // 2 - _layout_translate(root, dx, 0) - if target_h and abs(rb[3] - target_h) > 5: - dy = (target_h - rb[3]) // 2 - _layout_translate(root, 0, dy) - - # Collect results - nodes_out = {} - groups_out = {} - rb = root["_bindings"] - for child in root["children"]: - _layout_collect(child, nodes_out, groups_out) - - # Route connections - edges_out = [] - connections = tree.get("connections", []) - if connections: - edges_out = _layout_route_connections(connections, nodes_out, groups_out) - - # Build sdpm elements array - elements = [] - - # Groups (largest first for correct z-order) - for gid, g in sorted(groups_out.items(), key=lambda x: -x[1]["width"] * x[1]["height"]): - gt = g.get("groupType") - if not gt: - continue - elements.append({"type": "arch-group", "groupType": gt, "x": g["x"], "y": g["y"], "width": g["width"], "height": g["height"], "label": g.get("label", gid.rsplit(".", 1)[-1])}) - - # Nodes - is_dark = getattr(args, 'theme', 'dark') == 'dark' - for nid, n in nodes_out.items(): - if n.get("box"): - elements.extend(box_to_elements(nid, n, is_dark)) - elif n.get("icon"): - elements.append({"type": "image", "src": n["icon"], "x": n["x"], "y": n["y"], "width": n["width"], "label": n.get("label", nid.rsplit(".", 1)[-1]), "labelPosition": "bottom"}) - - # Edges as elbow connectors - # Track label positions for overlap avoidance - placed_labels = [] - - for e in edges_out: - pts = e["points"] - if len(pts) < 2: - continue - sx, sy = pts[0] - ex, ey = pts[-1] - - # Emit as polyline for: 3+ point paths (precise routing), fan-out, detours, complex - is_detour = len(pts) >= 4 and pts[0][1] == pts[-1][1] and any(p[1] != pts[0][1] for p in pts[1:-1]) - is_fanout = e.get("_fanout", False) - is_multipoint = len(pts) >= 3 - if is_detour or is_fanout or is_multipoint or len(pts) >= 6: - el = {"type": "line", "arrowEnd": "arrow", - "points": [[p[0], p[1]] for p in pts]} - elements.append(el) - else: - el = {"type": "line", "x1": sx, "y1": sy, "x2": ex, "y2": ey, "arrowEnd": "arrow"} - if len(pts) > 2: - el["connectorType"] = "elbow" - dx = ex - sx - dy = ey - sy - if len(pts) >= 4 and (abs(dx) > 0 or abs(dy) > 0): - # 4 points: [start, bend1, bend2, end] - # Determine if first segment is vertical based on points. - # If points have been shifted (non-axis-aligned), fall back to - # overall direction: if |dy| > |dx|, prefer V-H-V (bentConnector3) - seg1_vertical = abs(pts[0][0] - pts[1][0]) <= abs(pts[0][1] - pts[1][1]) - if abs(pts[0][0] - pts[1][0]) > 5 and abs(pts[0][1] - pts[1][1]) > 5: - seg1_vertical = abs(dy) > abs(dx) - if seg1_vertical: - # V-H-V → elbowStart vertical - # For U-shaped detour (sy==ty), use the bend Y relative to path height - if dy != 0: - adj = (pts[1][1] - sy) / dy - else: - # sy == ty: use max extent as reference - path_max_y = max(p[1] for p in pts) - path_min_y = min(p[1] for p in pts) - path_dy = path_max_y - sy if path_max_y > sy else path_min_y - sy - adj = (pts[1][1] - sy) / path_dy if path_dy != 0 else 0.5 - # Override: place end at same Y as start by using full extent - dy = path_dy - ey = sy + dy - el["y2"] = ey - el["preset"] = "bentConnector3" - el["elbowStart"] = "vertical" - el["adjustments"] = [max(-2.0, min(3.0, adj))] - else: - # H-V-H → bentConnector4 - adj1 = (pts[1][0] - sx) / dx if dx != 0 else 0.5 - adj2 = (pts[2][1] - sy) / dy if dy != 0 else 0.5 - # Clamp adj1: must go forward from start (min 0.15 = short horizontal stub) - adj1_min = 0.15 if dx > 0 else -1.0 - adj1_max = 2.0 if dx > 0 else -0.15 - el["preset"] = "bentConnector4" - el["adjustments"] = [max(adj1_min, min(adj1_max, adj1)), max(-1.0, min(2.0, adj2))] - elif dy != 0 or dx != 0: - el["adjustments"] = [0.5] - elements.append(el) - - label = e.get("label", "") - if not label: - continue - # Apply user labelOffset if provided - conn_obj = None - for c in (tree.get("connections") or []): - if c.get("from") == e["from"] and c.get("to") == e["to"]: - conn_obj = c - break - user_offset = (conn_obj or {}).get("labelOffset", {}) - - # Find longest segment midpoint - best_mid = None - best_len = -1 - best_horizontal = True - for si in range(len(pts) - 1): - ax, ay = pts[si] - bx, by = pts[si + 1] - seg_len = abs(bx - ax) + abs(by - ay) - if seg_len > best_len: - best_len = seg_len - best_mid = ((ax + bx) // 2, (ay + by) // 2) - best_horizontal = abs(bx - ax) > abs(by - ay) - mx, my = best_mid or ((pts[0][0] + pts[-1][0]) // 2, (pts[0][1] + pts[-1][1]) // 2) - - tw = max(len(label) * 11, 60) - th = 30 - arrow_len = abs(ex - sx) + abs(ey - sy) - - # Position: below for horizontal, right for vertical - if best_horizontal: - lx, ly = mx - tw // 2, my + 2 - # Short arrow: place label below arrow end - if arrow_len < tw + 20: - ly = my + 12 - else: - lx, ly = mx + 6, my - th // 2 + result = render_architecture( + tree, + x=args.x, y=args.y, width=args.width, height=args.height, + theme=getattr(args, "theme", "dark"), + include_metrics=False, + ) - # Apply user offset - lx += user_offset.get("x", 0) - ly += user_offset.get("y", 0) - - # Overlap avoidance: shift if colliding with existing labels - for px, py, pw, ph in placed_labels: - if lx < px + pw and lx + tw > px and ly < py + ph and ly + th > py: - if best_horizontal: - ly = py + ph + 2 - else: - ly = py + ph + 2 - placed_labels.append((lx, ly, tw, th)) - - elements.append({"type": "textbox", "x": lx, "y": ly, "width": tw, "height": th, "fontSize": 9, "align": "center", "verticalAlign": "top", "fill": "#000000", "opacity": 0.7, "line": "none", "marginTop": 0, "marginBottom": 0, "marginLeft": 0, "marginRight": 0, "text": "{{#8FA7C4:" + label + "}}"}) - - output = { - "elements": elements, - "bbox": {"x": rb[0], "y": rb[1], "width": rb[2], "height": rb[3]}, - } - - # Generate layout feedback - warnings = [] - if target_w: - ratio_w = rb[2] / target_w - if ratio_w < 0.5: - warnings.append(f"Layout uses only {round(ratio_w*100)}% of target width ({rb[2]}px / {target_w}px). Consider placing top-level groups horizontally.") - if rb[2] > target_w: - warnings.append(f"Layout width {rb[2]}px exceeds target {target_w}px. Consider reducing horizontal elements or splitting into multiple rows.") - if target_h: - ratio_h = rb[3] / target_h - if ratio_h < 0.5: - warnings.append(f"Layout uses only {round(ratio_h*100)}% of target height ({rb[3]}px / {target_h}px). Consider adding vertical spacing or stacking groups vertically.") - if rb[3] > target_h: - warnings.append(f"Layout height {rb[3]}px exceeds target {target_h}px. Consider reducing nesting depth or placing groups horizontally.") - if hasattr(args, '_cumulative_scale'): - if cumulative_sh < 0.5: - warnings.append(f"Horizontal spacing compressed to {round(cumulative_sh*100)}%. Consider reducing horizontal elements.") - if cumulative_sv < 0.5: - warnings.append(f"Vertical spacing compressed to {round(cumulative_sv*100)}%. Consider reducing vertical stacking.") - - # Check per-group size - for gid, g in groups_out.items(): - children = g.get("children", []) - if len(children) >= 3: - glabel = g.get("label", gid.rsplit(".", 1)[-1]) - if target_h and g["height"] > (target_h * 0.6): - warnings.append(f"Group \"{glabel}\" is tall ({g['height']}px). Consider direction: horizontal for its children.") - if target_w and g["width"] > (target_w * 0.8): - warnings.append(f"Group \"{glabel}\" is wide ({g['width']}px). Consider direction: vertical for its children.") - - # Check label overlaps - label_rects = [] - for nid, n in nodes_out.items(): - lbl = n.get("label", "") - if lbl: - lw = len(lbl) * 8 + 10 - lh = 20 - lx = n["x"] + (n["width"] - lw) / 2 - ly = n["y"] + n["height"] - label_rects.append((nid, lbl, lx, ly, lw, lh)) - for i in range(len(label_rects)): - for j in range(i + 1, len(label_rects)): - _, l1, x1, y1, w1, h1 = label_rects[i] - _, l2, x2, y2, w2, h2 = label_rects[j] - gap = 5 - if x1 - gap < x2 + w2 and x1 + w1 + gap > x2 and y1 - gap < y2 + h2 and y1 + h1 + gap > y2: - warnings.append(f"Labels \"{l1}\" and \"{l2}\" overlap. Increase spacing or shorten labels.") - - # Check edge-node crossings - margin = 5 - crossing_reported = set() - for e in edges_out: - pts = e["points"] - if len(pts) < 2: - continue - src_id, dst_id = e["from"], e["to"] - edge_key = f"{src_id}→{dst_id}" - for seg_i in range(len(pts) - 1): - x1, y1 = pts[seg_i] - x2, y2 = pts[seg_i + 1] - seg_min_x, seg_max_x = min(x1, x2), max(x1, x2) - seg_min_y, seg_max_y = min(y1, y2), max(y1, y2) - for nid, n in nodes_out.items(): - if nid.endswith(src_id) or nid.endswith(dst_id): - continue - report_key = (edge_key, n.get("label", nid)) - if report_key in crossing_reported: - continue - nx, ny, nw, nh = n["x"], n["y"], n["width"], n["height"] - if seg_max_x > nx + margin and seg_min_x < nx + nw - margin and seg_max_y > ny + margin and seg_min_y < ny + nh - margin: - warnings.append(f'Edge {edge_key} passes through node "{n.get("label", nid)}".') - crossing_reported.add(report_key) - - # Check edge-edge crossings (segment intersection) - edge_crossing_reported = set() - for i in range(len(edges_out)): - pts_i = edges_out[i]["points"] - if len(pts_i) < 2: - continue - for j in range(i + 1, len(edges_out)): - pts_j = edges_out[j]["points"] - if len(pts_j) < 2: - continue - crossed = False - for si in range(len(pts_i) - 1): - if crossed: - break - for sj in range(len(pts_j) - 1): - if _segments_cross(pts_i[si], pts_i[si + 1], pts_j[sj], pts_j[sj + 1]): - key_ij = (min(i, j), max(i, j)) - if key_ij not in edge_crossing_reported: - e_i = f"{edges_out[i]['from']}→{edges_out[i]['to']}" - e_j = f"{edges_out[j]['from']}→{edges_out[j]['to']}" - warnings.append(f"Edges {e_i} and {e_j} cross.") - edge_crossing_reported.add(key_ij) - crossed = True - break - - # Group-frame pierce: an edge slices through a framed group's box without - # connecting to that group or any icon inside it. The engine auto-detours - # only when it can FULLY clear the box; a residual pierce here is a - # structural problem the author must fix (the line has nowhere clean to go - # because an unrelated container sits across its path). - gframe_reported = set() - for e in edges_out: - pts = e["points"] - if len(pts) < 2: - continue - efrom = e["from"].rsplit(".", 1)[-1] - eto = e["to"].rsplit(".", 1)[-1] - for gid, g in groups_out.items(): - if not g.get("groupType"): - continue - gshort = gid.rsplit(".", 1)[-1] - if efrom == gshort or eto == gshort: - continue - members = _group_member_ids(nodes_out, groups_out, gid) - if efrom in members or eto in members: - continue - key = (e["from"], e["to"], gid) - if key in gframe_reported: - continue - if any(_seg_crosses_box(pts[k], pts[k + 1], g["x"], g["y"], - g["width"], g["height"], 2) - for k in range(len(pts) - 1)): - glabel = g.get("label", gshort) - warnings.append( - f'Edge {e["from"]}→{e["to"]} passes through group ' - f'"{glabel}" without connecting to it.') - gframe_reported.add(key) - - # Structure suggestions: sibling size imbalance - all_items = {} - all_items.update(groups_out) - all_items.update(nodes_out) - for gid, g in groups_out.items(): - child_ids = g.get("children", []) - if len(child_ids) < 2: - continue - has_group_child = any(cid in groups_out for cid in child_ids) - if not has_group_child: - continue - child_bboxes = [] - for cid in child_ids: - c = all_items.get(cid) - if c: - child_bboxes.append((cid, c)) - if len(child_bboxes) < 2: - continue - direction = g.get("direction", "horizontal") - axis = "height" if direction == "horizontal" else "width" - # Only compare group children (skip leaf nodes) - group_children = [(cid, c) for cid, c in child_bboxes if cid in groups_out] - if len(group_children) < 2: - continue - sizes = [(cid, c[axis]) for cid, c in group_children] - max_cid, max_s = max(sizes, key=lambda x: x[1]) - min_cid, min_s = min(sizes, key=lambda x: x[1]) - if min_s <= 0 or max_s / min_s < 2.0: - continue - max_label = all_items.get(max_cid, {}).get("label", max_cid) - min_label = all_items.get(min_cid, {}).get("label", min_cid) - ratio = max_s / min_s - # Add packing efficiency as supplementary info - pad = g.get("_padding", {}) - content_w = g["width"] - pad.get("left", 0) - pad.get("right", 0) - content_h = g["height"] - pad.get("top", 0) - pad.get("bottom", 0) - content_area = max(content_w, 1) * max(content_h, 1) - child_area = sum(c["width"] * c["height"] for _, c in child_bboxes) - eff = round(child_area / content_area * 100) - warnings.append(f"Group \"{g.get('label', g.get('id', '?'))}\" children {axis} imbalance: \"{max_label}\"={max_s}px vs \"{min_label}\"={min_s}px (ratio {ratio:.1f}:1, packing {eff}%). Consider redistributing children or changing direction. Note: restructuring may affect arrow routing.") + elements = result["elements"] + bb = result["bbox"] + warnings = result.get("warnings", []) + output = {"elements": elements, "bbox": bb} if warnings: output["warnings"] = warnings @@ -860,15 +430,13 @@ def build_root(): out_path.parent.mkdir(parents=True, exist_ok=True) write_json(out_path, {"elements": elements}) print(f"Generated: {out_path}") - print(f"bbox: x={rb[0]} y={rb[1]} width={rb[2]} height={rb[3]}") + print(f"bbox: x={bb['x']} y={bb['y']} width={bb['width']} height={bb['height']}") for w in warnings: print(f"⚠️ {w}") else: print(json.dumps(output, indent=2, ensure_ascii=False)) - - def cmd_analyze_template(args): """Analyze a PPTX template: extract layouts, theme, and placeholder details.""" from sdpm.analyzer import analyze_template, get_layout_placeholders diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 3759da02..ae6f6769 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -2445,6 +2445,59 @@ def _segments_overlap_collinear(a1, a2, b1, b2): return False +def _segments_cross(a1, a2, b1, b2): + """Test if two axis-aligned line segments (a1-a2) and (b1-b2) cross or overlap. + + Detects: + 1. Perpendicular crossings (one horizontal, one vertical) + 2. Collinear overlap (parallel segments sharing the same axis with overlapping range) + + Used by the builder's conservative edge-crossing warning. Distinct from + ``_segments_intersect`` (which counts T-junctions via ``_perp_touch``): this + one uses strict interior ``<`` on both segments so a shared endpoint does not + read as a crossing. + """ + ax1, ay1 = a1 + ax2, ay2 = a2 + bx1, by1 = b1 + bx2, by2 = b2 + + a_horiz = ay1 == ay2 + a_vert = ax1 == ax2 + b_horiz = by1 == by2 + b_vert = bx1 == bx2 + + # Perpendicular crossings + if a_horiz and b_vert: + h_y = ay1 + h_x_min, h_x_max = min(ax1, ax2), max(ax1, ax2) + v_x = bx1 + v_y_min, v_y_max = min(by1, by2), max(by1, by2) + return h_x_min < v_x < h_x_max and v_y_min < h_y < v_y_max + if a_vert and b_horiz: + v_x = ax1 + v_y_min, v_y_max = min(ay1, ay2), max(ay1, ay2) + h_y = by1 + h_x_min, h_x_max = min(bx1, bx2), max(bx1, bx2) + return h_x_min < v_x < h_x_max and v_y_min < h_y < v_y_max + + # Collinear overlap: both horizontal on same Y + if a_horiz and b_horiz and ay1 == by1: + a_min, a_max = min(ax1, ax2), max(ax1, ax2) + b_min, b_max = min(bx1, bx2), max(bx1, bx2) + overlap = min(a_max, b_max) - max(a_min, b_min) + return overlap > 5 + + # Collinear overlap: both vertical on same X + if a_vert and b_vert and ax1 == bx1: + a_min, a_max = min(ay1, ay2), max(ay1, ay2) + b_min, b_max = min(by1, by2), max(by1, by2) + overlap = min(a_max, b_max) - max(a_min, b_min) + return overlap > 5 + + return False + + def _count_all_crossings(edges): """Count crossing pairs across all edges. diff --git a/skill/sdpm/layout/metrics.py b/skill/sdpm/layout/metrics.py new file mode 100644 index 00000000..6119c348 --- /dev/null +++ b/skill/sdpm/layout/metrics.py @@ -0,0 +1,250 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +"""Objective quality metrics for a routed architecture layout. + +Given the collected ``nodes``/``groups``/``edges`` produced by the layout +pipeline (see :func:`sdpm.layout.render.build_layout`), measure geometric +quality: + + - crossings : pairs of edge segments that intersect + - pierces : edge segments passing through a non-endpoint node icon + - group_pierces : edge segments passing through an unrelated framed group box + - diagonals : segments that are neither horizontal nor vertical + - bad_ports : first/last segment not perpendicular to its node edge + - backwards : first segment travels opposite to the port's outward normal + +``measure_layout`` operates on already-collected geometry; ``measure`` is the +convenience wrapper that runs the pipeline first (used by the QA CLI). Both +return the same dict shape, including a lexicographic ``score``. +""" + +from . import ( + _count_all_crossings, + _count_group_pierces, + _find_node, + _seg_pierces_node, +) + +_TOL = 2 + + +def _seg_pierces_rect(p1, p2, n): + # Use the engine's own pierce definition so the QA metric and the + # optimizer's cost function agree on what counts as a pierce/graze. + return _seg_pierces_node(p1, p2, n) + + +def _side_of(node, pt): + """Classify which edge a port point sits on. + + Ports on the bottom edge are offset downward by the label height, so a + point below the icon that is horizontally inside the icon's x-span is a + bottom port (not a left/right one). Likewise points above are top ports. + """ + x, y = pt + cx, cy = node["x"], node["y"] + w = node.get("width", 60) + h = node.get("height", w) + # Inside the icon's horizontal span and at/below or at/above → top/bottom port + if cx - _TOL <= x <= cx + w + _TOL: + if y >= cy + h - _TOL: + return "bottom" + if y <= cy + _TOL: + return "top" + # Inside the icon's vertical span → left/right port + if cy - _TOL <= y <= cy + h + _TOL: + if x <= cx + _TOL: + return "left" + if x >= cx + w - _TOL: + return "right" + # Fallback: nearest edge + d = { + "left": abs(x - cx), + "right": abs(x - (cx + w)), + "top": abs(y - cy), + "bottom": abs(y - (cy + h)), + } + return min(d, key=d.get) + + +def measure_layout(nodes, groups, edges, bbox, target_w=1720, target_h=800): + """Measure geometric quality of an already-routed layout. + + ``bbox`` is the root ``_bindings`` list ``[x, y, w, h]``. + """ + rb = bbox + crossings = _count_all_crossings(edges) + group_pierces = _count_group_pierces(edges, groups, nodes) + + pierces = [] + diagonals = [] + bad_ports = [] + backwards = [] + wirelength = 0.0 + + for e in edges: + pts = e["points"] + if len(pts) < 2: + continue + + # total wire length (paths are axis-aligned, so Manhattan == polyline + # length). Minimizing this clusters connected icons together. + for k in range(len(pts) - 1): + wirelength += abs(pts[k][0] - pts[k + 1][0]) + abs(pts[k][1] - pts[k + 1][1]) + ig = {e["from"], e["to"]} + src = _find_node(nodes, e["from"]) + dst = _find_node(nodes, e["to"]) + + # pierces + for nid, n in nodes.items(): + short = nid.rsplit(".", 1)[-1] + if short in ig or nid in ig: + continue + for k in range(len(pts) - 1): + if _seg_pierces_rect(pts[k], pts[k + 1], n): + pierces.append((f"{e['from'].rsplit('.',1)[-1]}->{e['to'].rsplit('.',1)[-1]}", short)) + break + + # diagonals + for k in range(len(pts) - 1): + dx = abs(pts[k][0] - pts[k + 1][0]) + dy = abs(pts[k][1] - pts[k + 1][1]) + if dx > _TOL and dy > _TOL: + diagonals.append((f"{e['from'].rsplit('.',1)[-1]}->{e['to'].rsplit('.',1)[-1]}", pts[k], pts[k + 1])) + + # port perpendicularity + backwards (skip detours which legitimately exit bottom) + if src is not None: + ss = _side_of(src, pts[0]) + seg = (pts[0], pts[1]) + horiz = abs(seg[0][1] - seg[1][1]) <= _TOL + vert = abs(seg[0][0] - seg[1][0]) <= _TOL + exp_horiz = ss in ("left", "right") + if exp_horiz and not horiz: + bad_ports.append((f"src {e['from'].rsplit('.',1)[-1]}", ss, "not-horizontal")) + if (not exp_horiz) and not vert: + bad_ports.append((f"src {e['from'].rsplit('.',1)[-1]}", ss, "not-vertical")) + # backwards: exits right but first seg goes left, etc. + if ss == "right" and seg[1][0] < seg[0][0] - _TOL: + backwards.append(f"{e['from'].rsplit('.',1)[-1]}[right]->{e['to'].rsplit('.',1)[-1]}") + if ss == "left" and seg[1][0] > seg[0][0] + _TOL: + backwards.append(f"{e['from'].rsplit('.',1)[-1]}[left]->{e['to'].rsplit('.',1)[-1]}") + if ss == "bottom" and seg[1][1] < seg[0][1] - _TOL: + backwards.append(f"{e['from'].rsplit('.',1)[-1]}[bottom]->{e['to'].rsplit('.',1)[-1]}") + if ss == "top" and seg[1][1] > seg[0][1] + _TOL: + backwards.append(f"{e['from'].rsplit('.',1)[-1]}[top]->{e['to'].rsplit('.',1)[-1]}") + + if dst is not None: + ds = _side_of(dst, pts[-1]) + seg = (pts[-2], pts[-1]) + horiz = abs(seg[0][1] - seg[1][1]) <= _TOL + vert = abs(seg[0][0] - seg[1][0]) <= _TOL + exp_horiz = ds in ("left", "right") + if exp_horiz and not horiz: + bad_ports.append((f"dst {e['to'].rsplit('.',1)[-1]}", ds, "not-horizontal")) + if (not exp_horiz) and not vert: + bad_ports.append((f"dst {e['to'].rsplit('.',1)[-1]}", ds, "not-vertical")) + + w, h = rb[2], rb[3] + diag = (w * w + h * h) ** 0.5 or 1.0 + # normalize wirelength by the canvas diagonal so the soft term is + # comparable across differently-sized candidate layouts. + wire_norm = wirelength / diag + aspect = (w / h) if h else 0.0 + + # Overflow: how far the laid-out bounds exceed the slide frame after + # scale-to-fit. A tall (vertical-root) layout can stop shrinking once its + # icons+labels hit their minimum size, leaving height > target_h — those + # icons render off-slide. This is unusable regardless of crossing count, + # so it ranks ABOVE crossings in the score. Expressed as a fraction of + # the target dimension (0 == fits). + overflow = 0.0 + if target_w and w > target_w: + overflow += (w - target_w) / target_w + if target_h and h > target_h: + overflow += (h - target_h) / target_h + + result = { + "crossings": crossings, + "pierces": len(pierces), + "group_pierces": group_pierces, + "diagonals": len(diagonals), + "bad_ports": len(bad_ports), + "backwards": len(backwards), + "overflow": round(overflow, 3), + "wirelength": round(wirelength), + "wire_norm": round(wire_norm, 3), + "aspect": round(aspect, 3), + "size": [round(w), round(h)], + "detail": { + "pierces": pierces, + "diagonals": diagonals, + "bad_ports": bad_ports, + "backwards": backwards, + }, + } + result["score"] = score(result) + return result + + +def measure(tree, target_w=1720, target_h=800): + """Run the layout pipeline on ``tree`` then measure it.""" + from .render import build_layout + nodes, groups, edges, rb, _cum_h, _cum_v = build_layout(tree, None, None, target_w, target_h) + return measure_layout(nodes, groups, edges, rb, target_w, target_h) + + +# Aspect ratio considered visually comfortable for a 16:9 slide body. +_ASPECT_LO, _ASPECT_HI = 1.4, 3.2 + +# Geometric-defect weights, combined into ONE additive layer so the search +# can't trade one defect class for a worse total of another (e.g. drive +# crossings to 0 by introducing 12 pierces). A pierce (arrow through a +# non-endpoint icon) reads worse than a crossing, so it is weighted heavier; +# a backwards segment is a softer wrongness than either. +_W_CROSS = 1.0 +_W_PIERCE = 1.5 +_W_GROUP_PIERCE = 1.0 +_W_BACK = 0.7 +_W_BADPORT = 0.5 + + +def score(m): + """Multi-objective score; lower is better. + + Two layers, compared lexicographically: + 1. overflow — does the layout spill off the slide frame? Off-slide + icons are unusable regardless of routing quality, so any + real overflow outranks everything below. + 2. defects — a single WEIGHTED SUM of geometric defects (crossings, + pierces, backwards, bad ports/diagonals). Combining them + additively (rather than as separate lexicographic tiers) + prevents the degenerate trade where the search zeroes one + defect class by inflating another. + Soft aesthetic terms (wire length, aspect penalty) break ties within the + defect layer. This is the "judge" that picks which position-shifted + candidate is actually good. + """ + aspect = m["aspect"] + if aspect < _ASPECT_LO: + aspect_pen = _ASPECT_LO - aspect + elif aspect > _ASPECT_HI: + aspect_pen = aspect - _ASPECT_HI + else: + aspect_pen = 0.0 + soft = m["wire_norm"] + 2.0 * aspect_pen + # Overflow bucketed to 0.1 (10% of a slide dimension) so small rounding + # jitter doesn't reorder otherwise-equal layouts, but any real off-slide + # spill outranks every geometric defect below it. + overflow_bucket = round(m.get("overflow", 0.0) * 10) + defects = ( + _W_CROSS * m["crossings"] + + _W_PIERCE * m["pierces"] + + _W_GROUP_PIERCE * m.get("group_pierces", 0) + + _W_BACK * m["backwards"] + + _W_BADPORT * (m["bad_ports"] + m["diagonals"]) + ) + return ( + overflow_bucket, + round(defects, 2), + round(soft, 3), + ) diff --git a/skill/sdpm/layout/render.py b/skill/sdpm/layout/render.py new file mode 100644 index 00000000..9a62813c --- /dev/null +++ b/skill/sdpm/layout/render.py @@ -0,0 +1,490 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +"""Architecture diagram renderer: logical-structure JSON → placed elements. + +This is the canonical pipeline. The ``layout`` CLI command and the QA metrics +harness both call into here instead of re-implementing the scale/route steps. + +- :func:`build_layout` runs the placement pipeline (optimize order → scale to + fit → translate → collect → route) and returns the collected geometry. +- :func:`render_architecture` builds the sdpm ``elements`` array, generates + human-readable ``warnings``, and attaches objective ``metrics``. + +The pipeline respects the root ``reverse`` flag so the measured geometry +matches what the CLI actually draws. +""" + +import copy + +from . import ( + _group_member_ids, + _layout_collect, + _layout_route_connections, + _layout_scale, + _layout_translate, + _seg_crosses_box, + _segments_cross, + box_to_elements, + cancel_cross_axis_squash, + measure_natural_child_sizes, + optimize_order, +) + +# Number of scale-to-fit refinement passes. Every real diagram converges in +# ~3 passes (the loop breaks once the fit ratio is within 3%); the cap is a +# safety bound. +_FIT_ITERATIONS = 8 + + +def build_layout(tree, x=None, y=None, width=None, height=None): + """Run the placement pipeline on a logical-structure ``tree``. + + Returns ``(nodes, groups, edges, root_bindings, cum_h, cum_v)`` where + ``root_bindings`` is ``[x, y, w, h]``. ``x``/``y`` offset the whole diagram + (default: origin); ``width``/``height`` are the scale-to-fit target box. + + ``tree`` is deep-copied, so the caller's dict is not mutated (aside from the + order-optimization pass, which operates on the copy). + """ + tree = copy.deepcopy(tree) + direction = tree.get("direction", "horizontal") + align = tree.get("align", "center") + reverse = tree.get("reverse", False) + + # Optimize node order within groups to minimize edge crossings. + optimize_order(tree) + + def build_root(): + root = {"id": "_root", + "children": copy.deepcopy(tree.get("children", tree.get("nodes", []))), + "direction": direction, "align": align} + if reverse: + root["reverse"] = True + return root + + # Pass 1: natural size. + root = build_root() + _layout_scale(root, direction, align) + + # Record each top-level group's NATURAL cross-axis size so we can later tell + # which groups fit on their own (before any global squash). + natural_sizes = measure_natural_child_sizes(tree, root) + + cum_h = 1.0 + cum_v = 1.0 + if width or height: + for _ in range(_FIT_ITERATIONS): + rb = root["_bindings"] + sx = width / rb[2] if width else 1.0 + sy = height / rb[3] if height else 1.0 + if abs(sx - 1.0) < 0.03 and abs(sy - 1.0) < 0.03: + break + cum_h *= sx + cum_v *= sy + root = build_root() + _layout_scale(root, direction, align, cum_h, cum_v) + # A single oversized group forces a global cross-axis squash that would + # crush short siblings. Cancel that squash on the groups that already + # fit (the slide may overflow — that's the oversized group's problem, + # flagged by a warning — but the groups that fit stay readable). + if cancel_cross_axis_squash(tree, natural_sizes, cum_h, cum_v, width, height): + root = build_root() + _layout_scale(root, direction, align, cum_h, cum_v) + + rb = root["_bindings"] + ox = (x or 0) - rb[0] + oy = (y or 0) - rb[1] + _layout_translate(root, ox, oy) + + # Center if still slightly off from target. + rb = root["_bindings"] + if width and abs(rb[2] - width) > 5: + dx = (width - rb[2]) // 2 + _layout_translate(root, dx, 0) + if height and abs(rb[3] - height) > 5: + dy = (height - rb[3]) // 2 + _layout_translate(root, 0, dy) + + # Collect results. + nodes_out = {} + groups_out = {} + for child in root["children"]: + _layout_collect(child, nodes_out, groups_out) + + edges_out = [] + connections = tree.get("connections", []) + if connections: + edges_out = _layout_route_connections(connections, nodes_out, groups_out) + + return nodes_out, groups_out, edges_out, root["_bindings"], cum_h, cum_v + + +def _build_elements(tree, nodes_out, groups_out, edges_out, is_dark): + """Build the sdpm elements array from collected geometry.""" + elements = [] + + # Groups (largest first for correct z-order). + for gid, g in sorted(groups_out.items(), key=lambda x: -x[1]["width"] * x[1]["height"]): + gt = g.get("groupType") + if not gt: + continue + elements.append({"type": "arch-group", "groupType": gt, "x": g["x"], "y": g["y"], + "width": g["width"], "height": g["height"], + "label": g.get("label", gid.rsplit(".", 1)[-1])}) + + # Nodes. + for nid, n in nodes_out.items(): + if n.get("box"): + elements.extend(box_to_elements(nid, n, is_dark)) + elif n.get("icon"): + elements.append({"type": "image", "src": n["icon"], "x": n["x"], "y": n["y"], + "width": n["width"], "label": n.get("label", nid.rsplit(".", 1)[-1]), + "labelPosition": "bottom"}) + + # Edges as elbow connectors. Track label positions for overlap avoidance. + placed_labels = [] + for e in edges_out: + pts = e["points"] + if len(pts) < 2: + continue + sx, sy = pts[0] + ex, ey = pts[-1] + + # Emit as polyline for: 3+ point paths (precise routing), fan-out, detours, complex + is_detour = len(pts) >= 4 and pts[0][1] == pts[-1][1] and any(p[1] != pts[0][1] for p in pts[1:-1]) + is_fanout = e.get("_fanout", False) + is_multipoint = len(pts) >= 3 + if is_detour or is_fanout or is_multipoint or len(pts) >= 6: + el = {"type": "line", "arrowEnd": "arrow", + "points": [[p[0], p[1]] for p in pts]} + elements.append(el) + else: + el = {"type": "line", "x1": sx, "y1": sy, "x2": ex, "y2": ey, "arrowEnd": "arrow"} + if len(pts) > 2: + el["connectorType"] = "elbow" + dx = ex - sx + dy = ey - sy + if len(pts) >= 4 and (abs(dx) > 0 or abs(dy) > 0): + # 4 points: [start, bend1, bend2, end] + # Determine if first segment is vertical based on points. + # If points have been shifted (non-axis-aligned), fall back to + # overall direction: if |dy| > |dx|, prefer V-H-V (bentConnector3) + seg1_vertical = abs(pts[0][0] - pts[1][0]) <= abs(pts[0][1] - pts[1][1]) + if abs(pts[0][0] - pts[1][0]) > 5 and abs(pts[0][1] - pts[1][1]) > 5: + seg1_vertical = abs(dy) > abs(dx) + if seg1_vertical: + # V-H-V → elbowStart vertical + # For U-shaped detour (sy==ty), use the bend Y relative to path height + if dy != 0: + adj = (pts[1][1] - sy) / dy + else: + # sy == ty: use max extent as reference + path_max_y = max(p[1] for p in pts) + path_min_y = min(p[1] for p in pts) + path_dy = path_max_y - sy if path_max_y > sy else path_min_y - sy + adj = (pts[1][1] - sy) / path_dy if path_dy != 0 else 0.5 + # Override: place end at same Y as start by using full extent + dy = path_dy + ey = sy + dy + el["y2"] = ey + el["preset"] = "bentConnector3" + el["elbowStart"] = "vertical" + el["adjustments"] = [max(-2.0, min(3.0, adj))] + else: + # H-V-H → bentConnector4 + adj1 = (pts[1][0] - sx) / dx if dx != 0 else 0.5 + adj2 = (pts[2][1] - sy) / dy if dy != 0 else 0.5 + # Clamp adj1: must go forward from start (min 0.15 = short horizontal stub) + adj1_min = 0.15 if dx > 0 else -1.0 + adj1_max = 2.0 if dx > 0 else -0.15 + el["preset"] = "bentConnector4" + el["adjustments"] = [max(adj1_min, min(adj1_max, adj1)), max(-1.0, min(2.0, adj2))] + elif dy != 0 or dx != 0: + el["adjustments"] = [0.5] + elements.append(el) + + label = e.get("label", "") + if not label: + continue + + # Apply user labelOffset if provided. + conn_obj = None + for c in (tree.get("connections") or []): + if c.get("from") == e["from"] and c.get("to") == e["to"]: + conn_obj = c + break + user_offset = (conn_obj or {}).get("labelOffset", {}) + + # Find longest segment midpoint. + best_mid = None + best_len = -1 + best_horizontal = True + for si in range(len(pts) - 1): + ax, ay = pts[si] + bx, by = pts[si + 1] + seg_len = abs(bx - ax) + abs(by - ay) + if seg_len > best_len: + best_len = seg_len + best_mid = ((ax + bx) // 2, (ay + by) // 2) + best_horizontal = abs(bx - ax) > abs(by - ay) + mx, my = best_mid or ((pts[0][0] + pts[-1][0]) // 2, (pts[0][1] + pts[-1][1]) // 2) + + tw = max(len(label) * 11, 60) + th = 30 + arrow_len = abs(ex - sx) + abs(ey - sy) + + # Position: below for horizontal, right for vertical. + if best_horizontal: + lx, ly = mx - tw // 2, my + 2 + # Short arrow: place label below arrow end + if arrow_len < tw + 20: + ly = my + 12 + else: + lx, ly = mx + 6, my - th // 2 + + # Apply user offset. + lx += user_offset.get("x", 0) + ly += user_offset.get("y", 0) + + # Overlap avoidance: shift if colliding with existing labels. + for px, py, pw, ph in placed_labels: + if lx < px + pw and lx + tw > px and ly < py + ph and ly + th > py: + if best_horizontal: + ly = py + ph + 2 + else: + ly = py + ph + 2 + placed_labels.append((lx, ly, tw, th)) + + elements.append({"type": "textbox", "x": lx, "y": ly, "width": tw, "height": th, + "fontSize": 9, "align": "center", "verticalAlign": "top", + "fill": "#000000", "opacity": 0.7, "line": "none", + "marginTop": 0, "marginBottom": 0, "marginLeft": 0, "marginRight": 0, + "text": "{{#8FA7C4:" + label + "}}"}) + + return elements + + +def _build_warnings(nodes_out, groups_out, edges_out, rb, + target_w, target_h, cum_h, cum_v, scaled): + """Generate human-readable layout warnings (facts, not prescriptions).""" + warnings = [] + if target_w: + ratio_w = rb[2] / target_w + if ratio_w < 0.5: + warnings.append(f"Layout uses only {round(ratio_w*100)}% of target width ({rb[2]}px / {target_w}px). Consider placing top-level groups horizontally.") + if rb[2] > target_w: + warnings.append(f"Layout width {rb[2]}px exceeds target {target_w}px. Consider reducing horizontal elements or splitting into multiple rows.") + if target_h: + ratio_h = rb[3] / target_h + if ratio_h < 0.5: + warnings.append(f"Layout uses only {round(ratio_h*100)}% of target height ({rb[3]}px / {target_h}px). Consider adding vertical spacing or stacking groups vertically.") + if rb[3] > target_h: + warnings.append(f"Layout height {rb[3]}px exceeds target {target_h}px. Consider reducing nesting depth or placing groups horizontally.") + if scaled: + if cum_h < 0.5: + warnings.append(f"Horizontal spacing compressed to {round(cum_h*100)}%. Consider reducing horizontal elements.") + if cum_v < 0.5: + warnings.append(f"Vertical spacing compressed to {round(cum_v*100)}%. Consider reducing vertical stacking.") + + # Check per-group size. + for gid, g in groups_out.items(): + children = g.get("children", []) + if len(children) >= 3: + glabel = g.get("label", gid.rsplit(".", 1)[-1]) + if target_h and g["height"] > (target_h * 0.6): + warnings.append(f"Group \"{glabel}\" is tall ({g['height']}px). Consider direction: horizontal for its children.") + if target_w and g["width"] > (target_w * 0.8): + warnings.append(f"Group \"{glabel}\" is wide ({g['width']}px). Consider direction: vertical for its children.") + + # Check label overlaps. + label_rects = [] + for nid, n in nodes_out.items(): + lbl = n.get("label", "") + if lbl: + lw = len(lbl) * 8 + 10 + lh = 20 + lx = n["x"] + (n["width"] - lw) / 2 + ly = n["y"] + n["height"] + label_rects.append((nid, lbl, lx, ly, lw, lh)) + for i in range(len(label_rects)): + for j in range(i + 1, len(label_rects)): + _, l1, x1, y1, w1, h1 = label_rects[i] + _, l2, x2, y2, w2, h2 = label_rects[j] + gap = 5 + if x1 - gap < x2 + w2 and x1 + w1 + gap > x2 and y1 - gap < y2 + h2 and y1 + h1 + gap > y2: + warnings.append(f"Labels \"{l1}\" and \"{l2}\" overlap. Increase spacing or shorten labels.") + + # Check edge-node crossings. + margin = 5 + crossing_reported = set() + for e in edges_out: + pts = e["points"] + if len(pts) < 2: + continue + src_id, dst_id = e["from"], e["to"] + edge_key = f"{src_id}→{dst_id}" + for seg_i in range(len(pts) - 1): + x1, y1 = pts[seg_i] + x2, y2 = pts[seg_i + 1] + seg_min_x, seg_max_x = min(x1, x2), max(x1, x2) + seg_min_y, seg_max_y = min(y1, y2), max(y1, y2) + for nid, n in nodes_out.items(): + if nid.endswith(src_id) or nid.endswith(dst_id): + continue + report_key = (edge_key, n.get("label", nid)) + if report_key in crossing_reported: + continue + nx, ny, nw, nh = n["x"], n["y"], n["width"], n["height"] + if seg_max_x > nx + margin and seg_min_x < nx + nw - margin and seg_max_y > ny + margin and seg_min_y < ny + nh - margin: + warnings.append(f'Edge {edge_key} passes through node "{n.get("label", nid)}".') + crossing_reported.add(report_key) + + # Check edge-edge crossings (segment intersection). + edge_crossing_reported = set() + for i in range(len(edges_out)): + pts_i = edges_out[i]["points"] + if len(pts_i) < 2: + continue + for j in range(i + 1, len(edges_out)): + pts_j = edges_out[j]["points"] + if len(pts_j) < 2: + continue + crossed = False + for si in range(len(pts_i) - 1): + if crossed: + break + for sj in range(len(pts_j) - 1): + if _segments_cross(pts_i[si], pts_i[si + 1], pts_j[sj], pts_j[sj + 1]): + key_ij = (min(i, j), max(i, j)) + if key_ij not in edge_crossing_reported: + e_i = f"{edges_out[i]['from']}→{edges_out[i]['to']}" + e_j = f"{edges_out[j]['from']}→{edges_out[j]['to']}" + warnings.append(f"Edges {e_i} and {e_j} cross.") + edge_crossing_reported.add(key_ij) + crossed = True + break + + # Group-frame pierce: an edge slices through a framed group's box without + # connecting to that group or any icon inside it. The engine auto-detours + # only when it can FULLY clear the box; a residual pierce here is a + # structural problem the author must fix (the line has nowhere clean to go + # because an unrelated container sits across its path). + gframe_reported = set() + for e in edges_out: + pts = e["points"] + if len(pts) < 2: + continue + efrom = e["from"].rsplit(".", 1)[-1] + eto = e["to"].rsplit(".", 1)[-1] + for gid, g in groups_out.items(): + if not g.get("groupType"): + continue + gshort = gid.rsplit(".", 1)[-1] + if efrom == gshort or eto == gshort: + continue + members = _group_member_ids(nodes_out, groups_out, gid) + if efrom in members or eto in members: + continue + key = (e["from"], e["to"], gid) + if key in gframe_reported: + continue + if any(_seg_crosses_box(pts[k], pts[k + 1], g["x"], g["y"], + g["width"], g["height"], 2) + for k in range(len(pts) - 1)): + glabel = g.get("label", gshort) + warnings.append( + f'Edge {e["from"]}→{e["to"]} passes through group ' + f'"{glabel}" without connecting to it.') + gframe_reported.add(key) + + # Structure suggestions: sibling size imbalance. + all_items = {} + all_items.update(groups_out) + all_items.update(nodes_out) + for gid, g in groups_out.items(): + child_ids = g.get("children", []) + if len(child_ids) < 2: + continue + has_group_child = any(cid in groups_out for cid in child_ids) + if not has_group_child: + continue + child_bboxes = [] + for cid in child_ids: + c = all_items.get(cid) + if c: + child_bboxes.append((cid, c)) + if len(child_bboxes) < 2: + continue + direction = g.get("direction", "horizontal") + axis = "height" if direction == "horizontal" else "width" + # Only compare group children (skip leaf nodes). + group_children = [(cid, c) for cid, c in child_bboxes if cid in groups_out] + if len(group_children) < 2: + continue + sizes = [(cid, c[axis]) for cid, c in group_children] + max_cid, max_s = max(sizes, key=lambda x: x[1]) + min_cid, min_s = min(sizes, key=lambda x: x[1]) + if min_s <= 0 or max_s / min_s < 2.0: + continue + max_label = all_items.get(max_cid, {}).get("label", max_cid) + min_label = all_items.get(min_cid, {}).get("label", min_cid) + ratio = max_s / min_s + # Add packing efficiency as supplementary info. + pad = g.get("_padding", {}) + content_w = g["width"] - pad.get("left", 0) - pad.get("right", 0) + content_h = g["height"] - pad.get("top", 0) - pad.get("bottom", 0) + content_area = max(content_w, 1) * max(content_h, 1) + child_area = sum(c["width"] * c["height"] for _, c in child_bboxes) + eff = round(child_area / content_area * 100) + warnings.append(f"Group \"{g.get('label', g.get('id', '?'))}\" children {axis} imbalance: \"{max_label}\"={max_s}px vs \"{min_label}\"={min_s}px (ratio {ratio:.1f}:1, packing {eff}%). Consider redistributing children or changing direction. Note: restructuring may affect arrow routing.") + + return warnings + + +def render_architecture(tree, x=None, y=None, width=None, height=None, + theme="dark", include_metrics=True): + """Render a logical-structure ``tree`` to placed sdpm elements. + + Returns a dict with: + - ``elements``: sdpm element array (arch-groups, images/boxes, connectors) + - ``bbox``: final bounding box ``{x, y, width, height}`` after scale-to-fit + - ``warnings``: human-readable facts about layout defects (may be empty) + - ``metrics``: objective QA metrics (crossings/pierces/group_pierces/ + overflow/score, …) when ``include_metrics`` is True + + ``targetArea`` inside the tree (``{x, y, width, height}``) overrides the + corresponding argument when that argument is falsy. + """ + target_area = tree.get("targetArea", {}) + if target_area: + if "x" in target_area and not x: + x = target_area["x"] + if "y" in target_area and not y: + y = target_area["y"] + if "width" in target_area and not width: + width = target_area["width"] + if "height" in target_area and not height: + height = target_area["height"] + + nodes_out, groups_out, edges_out, rb, cum_h, cum_v = build_layout( + tree, x, y, width, height) + + is_dark = theme == "dark" + elements = _build_elements(tree, nodes_out, groups_out, edges_out, is_dark) + warnings = _build_warnings(nodes_out, groups_out, edges_out, rb, + width, height, cum_h, cum_v, bool(width or height)) + + output = { + "elements": elements, + "bbox": {"x": rb[0], "y": rb[1], "width": rb[2], "height": rb[3]}, + } + if warnings: + output["warnings"] = warnings + + if include_metrics: + from .metrics import measure_layout + output["metrics"] = measure_layout( + nodes_out, groups_out, edges_out, rb, + width or 1720, height or 800) + + return output From 231dbd11b9e37abc784de67649918d8c39627203 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Wed, 8 Jul 2026 20:15:08 +0900 Subject: [PATCH 62/79] feat(mcp): expose arch_diagram tool (auto-layout + QA metrics) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register the architecture-diagram layout engine as a first-class MCP tool so clients can render diagrams directly instead of driving it through the generic run_python sandbox. - L2 (mcp-local): add tools.arch_diagram, registered in server.py, server_with_instruction.py, server_acp.py (same 1-line pattern as grid). - L3 (mcp-server): add @mcp.tool() arch_diagram wrapping the same sdpm.layout.render.render_architecture pipeline. The tool takes a logical-structure JSON string plus target-area params and returns {elements, bbox, warnings, metrics}. Including the QA metrics (crossings/pierces/group_pierces/overflow/score) lets the agent render, read the numbers, and self-correct the structure in one loop — previously this needed the layout CLI plus a separate QA script. Verified: L2 server lists arch_diagram among 21 tools with the expected params; render path returns metrics; bad JSON returns a clean error. ruff clean, 188 tests pass. --- mcp-local/server.py | 1 + mcp-local/server_acp.py | 1 + mcp-local/server_with_instruction.py | 1 + mcp-local/tools.py | 55 ++++++++++++++++++++++++++++ mcp-server/server.py | 51 ++++++++++++++++++++++++++ 5 files changed, 109 insertions(+) diff --git a/mcp-local/server.py b/mcp-local/server.py index 5f534530..6c818cfe 100644 --- a/mcp-local/server.py +++ b/mcp-local/server.py @@ -92,6 +92,7 @@ mcp.tool()(tools.read_guides) mcp.tool()(tools.code_to_slide) mcp.tool()(tools.grid) +mcp.tool()(tools.arch_diagram) mcp.tool()(tools.pptx_to_json) # Sandbox tools (shared) diff --git a/mcp-local/server_acp.py b/mcp-local/server_acp.py index a13d153b..6756774e 100644 --- a/mcp-local/server_acp.py +++ b/mcp-local/server_acp.py @@ -52,6 +52,7 @@ mcp.tool()(tools.read_guides) mcp.tool()(tools.code_to_slide) mcp.tool()(tools.grid) +mcp.tool()(tools.arch_diagram) mcp.tool()(tools.pptx_to_json) # Sandbox tools (shared) diff --git a/mcp-local/server_with_instruction.py b/mcp-local/server_with_instruction.py index 7f4f2777..133e9fdf 100644 --- a/mcp-local/server_with_instruction.py +++ b/mcp-local/server_with_instruction.py @@ -40,6 +40,7 @@ mcp.tool()(tools.read_guides) mcp.tool()(tools.code_to_slide) mcp.tool()(tools.grid) +mcp.tool()(tools.arch_diagram) mcp.tool()(tools.pptx_to_json) # Sandbox tools (shared) diff --git a/mcp-local/tools.py b/mcp-local/tools.py index 2ef66495..d7aabe9b 100644 --- a/mcp-local/tools.py +++ b/mcp-local/tools.py @@ -322,6 +322,61 @@ def grid(purpose: str, spec: str) -> dict[str, Any]: return compute_grid(grid_spec) +def arch_diagram( + spec: str, + x: int = 100, y: int = 180, width: int = 1720, height: int = 800, + theme: str = "dark", +) -> dict[str, Any]: + """Auto-layout an architecture/flow diagram from a logical-structure JSON. + + Turns a nested structure of groups, icons and connections into fully-placed + slide elements with auto-routed orthogonal arrows. You describe *what + connects to what*; the engine computes coordinates, clusters related icons, + picks arrow ports/bends, and minimizes crossings and icon pierces. Prefer + this over hand-placing coordinates for any diagram with more than a few + connections. + + Read the guide `arch-layout-engine` (via read_guides) for the JSON schema + and the techniques that reach 0 crossings (connect to a GROUP not every + icon; `fan: "merge"` bundles; perpendicular branch wrappers). + + Args: + spec: JSON string. Top-level keys: `direction` ("horizontal"/"vertical"), + `iconSize`, `children` (nested nodes/groups), `connections` + (`{from, to, label?, fan?}`). A `targetArea` object inside the JSON + overrides x/y/width/height. + x: Target area X offset in px. + y: Target area Y offset in px. + width: Target area width in px (engine scales the diagram to fit). + height: Target area height in px. + theme: "dark" or "light" — affects box-node text colors. + + Returns: + Dict with: + - `elements`: sdpm element array — drop into a slide, or write to a + file and reference with `{"type": "include", "src": "..."}`. + - `bbox`: final bounding box after scale-to-fit. + - `warnings`: human-readable facts about layout defects (facts, not + prescriptions) — may be absent when clean. + - `metrics`: objective QA numbers. `crossings` / `pierces` / + `group_pierces` are 0 for a clean diagram; `overflow` > 0 means the + layout spills off the target box; `score` is the internal judge's + lexicographic tuple (lower is better). Inspect these and iterate on + STRUCTURE (not coordinates) when defects remain. + """ + import json + from sdpm.layout.render import render_architecture + + try: + tree = json.loads(spec) + except (json.JSONDecodeError, TypeError) as e: + return {"error": f"Invalid diagram spec JSON: {e}"} + return render_architecture( + tree, x=x, y=y, width=width, height=height, theme=theme, + include_metrics=True, + ) + + def pptx_to_json(pptx_path: str) -> dict[str, Any]: """Convert an existing PPTX file to JSON representation. diff --git a/mcp-server/server.py b/mcp-server/server.py index 80ebf8f4..294ba0f6 100644 --- a/mcp-server/server.py +++ b/mcp-server/server.py @@ -943,6 +943,57 @@ def grid(spec: str, purpose: str = "") -> str: return json.dumps(result, ensure_ascii=False, indent=2) +@mcp.tool() +def arch_diagram( + spec: str, + x: int = 100, y: int = 180, width: int = 1720, height: int = 800, + theme: str = "dark", +) -> str: + """Auto-layout an architecture/flow diagram from a logical-structure JSON. + + Turns a nested structure of groups, icons and connections into fully-placed + slide elements with auto-routed orthogonal arrows. You describe *what + connects to what*; the engine computes coordinates, clusters related icons, + picks arrow ports/bends, and minimizes crossings and icon pierces. Prefer + this over hand-placing coordinates for any diagram with more than a few + connections. + + Read the guide `arch-layout-engine` (via read_guides) for the JSON schema + and the techniques that reach 0 crossings (connect to a GROUP not every + icon; `fan: "merge"` bundles; perpendicular branch wrappers). + + Args: + spec: JSON string. Top-level keys: `direction` ("horizontal"/"vertical"), + `iconSize`, `children` (nested nodes/groups), `connections` + (`{from, to, label?, fan?}`). A `targetArea` object inside the JSON + overrides x/y/width/height. + x: Target area X offset in px. + y: Target area Y offset in px. + width: Target area width in px (engine scales the diagram to fit). + height: Target area height in px. + theme: "dark" or "light" — affects box-node text colors. + + Returns: + JSON with `elements` (sdpm element array), `bbox` (final bounding box), + optional `warnings` (facts about layout defects), and `metrics` + (objective QA numbers: `crossings`/`pierces`/`group_pierces` are 0 for a + clean diagram; `overflow` > 0 means the layout spills off the box; + `score` is the judge's lexicographic tuple, lower is better). Inspect + these and iterate on STRUCTURE (not coordinates) when defects remain. + """ + from sdpm.layout.render import render_architecture + + try: + tree = json.loads(spec) + except (json.JSONDecodeError, TypeError) as e: + return json.dumps({"error": f"Invalid diagram spec JSON: {e}"}) + result = render_architecture( + tree, x=x, y=y, width=width, height=height, theme=theme, + include_metrics=True, + ) + return json.dumps(result, ensure_ascii=False, indent=2) + + # --- Style Execution (Code Interpreter) --- From b5e0a72c140176ba65cfd9e05e03511fd6909ba6 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Wed, 8 Jul 2026 21:23:59 +0900 Subject: [PATCH 63/79] docs(layout): document arch_diagram MCP tool alongside the CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The layout engine now has two equivalent front-ends (the arch_diagram MCP tool and the layout CLI). Update the guide so agents on either host know how to invoke it and read the results. - arch-layout-engine.md "How to run it": present arch_diagram (preferred — returns metrics inline) and the CLI side by side; clarify targetArea override applies to both. - "Reading the output": metrics are returned inline by arch_diagram; the CLI path still uses layout_qa.py. Document overflow/score and the render → read metrics → fix structure loop. - arch-elements.md: theme is an arg (MCP) or --theme flag (CLI). The discovery loop is closed both ways: the tool docstring points to this guide via read_guides; the guide names the tool as the preferred front-end. --- skill/references/guides/arch-elements.md | 2 +- skill/references/guides/arch-layout-engine.md | 47 ++++++++++++++----- 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/skill/references/guides/arch-elements.md b/skill/references/guides/arch-elements.md index 68b123fb..77500e5f 100644 --- a/skill/references/guides/arch-elements.md +++ b/skill/references/guides/arch-elements.md @@ -296,7 +296,7 @@ Minimal (label only): ### Rendering -The layout engine expands box nodes into existing element types (colors switch with `--theme`): +The layout engine expands box nodes into existing element types (colors switch with the `theme` arg / `--theme` flag): - `rounded_rectangle`: semi-transparent background (opacity 0.18) + border + shadow "sm" + corner radius (0.07) - `textbox`: sublabel (muted) → label (bold) → description (muted), with autofit diff --git a/skill/references/guides/arch-layout-engine.md b/skill/references/guides/arch-layout-engine.md index 4b5aabd2..a9ab440c 100644 --- a/skill/references/guides/arch-layout-engine.md +++ b/skill/references/guides/arch-layout-engine.md @@ -20,16 +20,33 @@ connections. Hand-placement is for fine-tuning or non-flow art. ## How to run it +Two equivalent front-ends call the same engine. Use whichever your host offers. + +**MCP tool (`arch_diagram`)** — preferred when available. Returns the routed +layout AND the QA metrics in one call, so you can render, read the numbers, and +fix the structure without a second command: + +``` +arch_diagram(spec="", + x=100, y=180, width=1720, height=800, theme="dark") +``` + +**CLI** — same engine, for SKILL.md/script hosts: + ```bash python3 scripts/pptx_builder.py layout input.json \ --x 100 --y 180 --width 1720 --height 800 -o elements.json ``` - Input: one logical-structure JSON (see below). -- Output: `{ "elements": [...], "bbox": {...}, "warnings": [...] }`. +- Output: `{ "elements": [...], "bbox": {...}, "warnings": [...] }`. The MCP tool + additionally returns `"metrics"` (crossings / pierces / group_pierces / + overflow / score — see "Reading the output"); the CLI omits it (run + `layout_qa.py` for the CLI path). - Drop the `elements` array straight into a slide, or reference the whole file with `{"type": "include", "src": "elements.json"}`. -- `targetArea` in the JSON (`{x, y, width, height}`) overrides the CLI flags. +- `targetArea` in the JSON (`{x, y, width, height}`) overrides the x/y/width/ + height args (both front-ends). - The engine scales the whole diagram to fit the target box, so author at any scale — relationships matter, absolute sizes don't. @@ -296,18 +313,24 @@ framed groups do. edge-crossing warning is conservative and may flag a shared fan trunk that is *not* a real crossing — trust the diagram and the QA metric over that one. - `bbox`: final bounding box after scale-to-fit. +- `metrics`: objective QA numbers, returned inline by the `arch_diagram` MCP + tool. For the CLI, get the same numbers from the QA harness: -For an objective check, the QA harness reports crossings / pierces / -group_pierces / overflow / score: + ```bash + python3 scripts/layout_qa.py input.json --width 1720 --height 800 + ``` -```bash -python3 scripts/layout_qa.py input.json --width 1720 --height 800 -``` + - `crossings` — edge segments that intersect. + - `pierces` — a line through a non-endpoint **icon**. + - `group_pierces` — a line through an unrelated **framed group box** (above). + - `overflow` — fraction the layout spills off the target box (0 == fits; + ranks worse than any crossing). + - `score` — the judge's lexicographic tuple `(overflow, weighted defects, + soft)`, lower is better. + - A clean diagram has crossings / pierces / group_pierces all at 0. -- `crossings` — edge segments that intersect. -- `pierces` — a line through a non-endpoint **icon**. -- `group_pierces` — a line through an unrelated **framed group box** (above). -- A clean diagram has all three at 0. + Loop on this: render → read `metrics` → change **structure** (not + coordinates) → re-render, until the three defect counts are 0. --- -**Updated**: 2026-06-30 +**Updated**: 2026-07-08 From 54c185886987f7a2d608a0da986d7caa72aeeef6 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Thu, 9 Jul 2026 09:43:33 +0900 Subject: [PATCH 64/79] feat(layout): auto-reflow anonymous tile pools to shorten wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A "tile pool" is a group whose children are all anonymous, frameless, leaf-only sub-columns of one orientation — pure tiling with no semantic sub-grouping (e.g. a Services group split into two unlabeled Lambda columns). Order optimization never moves a leaf between sub-columns, so the author's column split stays frozen even when regrouping would shorten wiring and seat externally-connected leaves nearer their peers. The new _reflow_tile_pools pass (end of optimize_order) enumerates the ways to partition a pool's leaves across its columns — membership only; each candidate's intra-column order is then set by the normal optimizer — re-routes each with the real engine, and keeps the best. A candidate is adopted only if it does not worsen any hard defect (overflow/crossings/pierces/group_pierces/backwards) vs the author's arrangement; wire length is a pure tie-break, so reflow can never trade a crossing for shorter wire. build_layout gains optimize=False so the reflow evaluation re-routes candidates without recursing into optimize_order. Only groups matching the strict tile-pool shape are touched (1 of 12 test diagrams); the other 11 render byte-identical. On the e-commerce diagram the engine now auto-reaches the hand-tuned layout: Orders/Payments drop to the row facing the Queue/Payment-Provider, wire 1.108 -> 0.907, 0 crossings. Membership search keeps it fast (~380ms). 260 tests pass. --- skill/sdpm/layout/__init__.py | 156 +++++++++++++++++++++++++++++++++- skill/sdpm/layout/render.py | 9 +- 2 files changed, 162 insertions(+), 3 deletions(-) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index ae6f6769..540f9efd 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -3,7 +3,7 @@ """Layout engine: compute coordinates from logical structure JSON.""" -def optimize_order(tree): +def optimize_order(tree, enable_reflow=True): """Pre-process: reorder children in groups to minimize edge crossings. Handles both leaf-only AND mixed groups (groups containing sub-groups). @@ -11,6 +11,10 @@ def optimize_order(tree): heuristic sorting for larger ones. Counts actual crossing pairs using a two-layer position model (internal positions + external peer positions). + ``enable_reflow`` runs the tile-pool reflow pass at the end. It is set + False when the reflow pass itself re-lays-out candidate arrangements, so + the real-routing evaluation does not recurse back into reflow. + Mutates tree in-place. Call before _layout_scale. """ connections = tree.get("connections", []) @@ -30,6 +34,13 @@ def optimize_order(tree): # for an edge to a sibling GROUP box having to detour around a nearer child # (see _count_crossings_for_mixed_order's peer-adjacency term). _optimize_group_order(tree, connections) + # Reflow tile pools: a group whose children are all anonymous, frameless, + # leaf-only sub-columns of one orientation (pure tiling with no semantic + # sub-grouping) can have its leaves reassigned across columns to shorten + # wiring — seating externally-connected leaves at the peer-facing end. + # Ordering alone can't do this (it never moves a leaf between sub-columns). + if enable_reflow: + _reflow_tile_pools(tree) def _tag_manual_branch_anchors(node, connections, root=None): @@ -259,6 +270,149 @@ def _optimize_group_order(node, connections, root=None): node["children"] = sorted(children, key=lambda c: _heuristic_sort_key_mixed(c, connections, id_position, child_leaf_ids)) +# Max leaves in a tile pool we will exhaustively reflow (n! candidate arrangements +# each re-routed; 6 → 720 is the practical ceiling for the local pass). +_REFLOW_MAX_LEAVES = 6 + + +def _is_tile_column(node): + """A tile is an anonymous, frameless, leaf-only sub-group (pure spacing, + no semantic meaning): no groupType, no label, no branch-anchor tag, and all + of its own children are leaves.""" + kids = node.get("children") + if not kids: + return False + if node.get("groupType") or node.get("label") or node.get("_branch_anchor"): + return False + return all(not k.get("children") for k in kids) + + +def _find_tile_pools(node, out): + """Collect groups that are pure tile pools. + + A tile pool is a group whose children are ALL anonymous frameless leaf-only + sub-columns (tiles) of the SAME orientation, with ≥2 tiles. Such a group is + a grid with no semantic sub-grouping, so its leaves are interchangeable + across tiles — safe to reassign to shorten wiring. + """ + kids = node.get("children", []) + if kids: + tiles = [k for k in kids if _is_tile_column(k)] + if len(tiles) >= 2 and len(tiles) == len(kids): + dirs = {t.get("direction") for t in tiles} + total = sum(len(t["children"]) for t in tiles) + if len(dirs) == 1 and 2 <= total <= _REFLOW_MAX_LEAVES: + out.append(node) + for k in kids: + _find_tile_pools(k, out) + + +def _defect_tuple(tree, width, height): + """Optimize child order (WITHOUT reflow), route the tree, and return the + lexicographic quality key used to compare tile arrangements: hard defects + first, then wire length as the soft tie-break. + + The intra-column order of each candidate is decided by the normal order + optimizer (reflow disabled so it can't recurse), so the reflow search only + has to explore how leaves are *partitioned* across columns — not their order + within a column.""" + import copy + from .render import build_layout + from .metrics import measure_layout + t = copy.deepcopy(tree) + optimize_order(t, enable_reflow=False) + nodes, groups, edges, rb, _ch, _cv = build_layout( + t, None, None, width, height, optimize=False) + m = measure_layout(nodes, groups, edges, rb, width, height) + return (round(m["overflow"], 3), m["crossings"], m["pierces"], + m["group_pierces"], m["backwards"], m["wire_norm"]) + + +def _column_partitions(leaf_ids, col_sizes): + """Yield every way to partition ``leaf_ids`` into ordered columns of the + given sizes, as a tuple of frozensets (membership only — intra-column order + is decided later by the order optimizer). Deduplicates equal-size columns so + (A|B) and (B|A) are not both tried.""" + from itertools import combinations + + def rec(remaining, sizes): + if not sizes: + yield () + return + size = sizes[0] + for combo in combinations(sorted(remaining), size): + rest = remaining - set(combo) + for tail in rec(rest, sizes[1:]): + yield (frozenset(combo),) + tail + + seen = set() + for parts in rec(set(leaf_ids), col_sizes): + # Canonicalize columns of equal size to dedupe symmetric partitions. + key = tuple(sorted(parts, key=lambda s: sorted(s))) + if key in seen: + continue + seen.add(key) + yield parts + + +def _reflow_tile_pools(tree, width=1720, height=800): + """Repartition leaves across the columns of each tile pool to shorten wiring. + + A tile pool is a group whose children are all anonymous, frameless, leaf-only + sub-columns of one orientation — pure tiling with no semantic sub-grouping. + Ordering alone never moves a leaf between sub-columns, so an author's column + split (e.g. Orders+Payments in one column, Catalog+Cart in the other) is + frozen even when regrouping would shorten wiring. + + For each pool we enumerate the ways to split its leaves across the columns + (membership only — each candidate's intra-column order is then set by the + normal order optimizer), re-route each with the REAL engine, and keep the + best. A candidate is kept only if it does not worsen any hard defect + (overflow/crossings/pierces/group_pierces/backwards) versus the author's + arrangement; wire length breaks ties. Because hard defects rank ahead of + wire, reflow can never trade a crossing for shorter wire — it is a pure + quality-preserving cleanup. + """ + pools = [] + _find_tile_pools(tree, pools) + if not pools: + return + + for pool in pools: + tiles = pool["children"] + col_sizes = [len(t["children"]) for t in tiles] + leaves = [leaf for t in tiles for leaf in t["children"]] + by_id = {leaf["id"]: leaf for leaf in leaves} + leaf_ids = [leaf["id"] for leaf in leaves] + + def apply_partition(parts): + """Fill each tile column with the members of the corresponding + partition set (intra-column order is refined later by the optimizer, + so any stable order is fine here).""" + for ci, members in enumerate(parts): + tiles[ci]["children"] = [by_id[i] for i in leaf_ids if i in members] + + # Baseline: the author's arrangement, order-optimized. + author_parts = tuple( + frozenset(leaf["id"] for leaf in tile["children"]) for tile in tiles) + best_parts = author_parts + best_key = _defect_tuple(tree, width, height) + + for parts in _column_partitions(leaf_ids, col_sizes): + if parts == author_parts: + continue + apply_partition(parts) + key = _defect_tuple(tree, width, height) + if key < best_key: + best_key = key + best_parts = parts + + apply_partition(best_parts) + # Let the order optimizer set the final intra-column order for the chosen + # partition (reflow disabled to avoid recursing into this pass). + optimize_order(tree, enable_reflow=False) + + def _collect_leaf_ids(node, out): """Collect all leaf node ids reachable from a node.""" children = node.get("children", []) diff --git a/skill/sdpm/layout/render.py b/skill/sdpm/layout/render.py index 9a62813c..5fef9d57 100644 --- a/skill/sdpm/layout/render.py +++ b/skill/sdpm/layout/render.py @@ -36,7 +36,7 @@ _FIT_ITERATIONS = 8 -def build_layout(tree, x=None, y=None, width=None, height=None): +def build_layout(tree, x=None, y=None, width=None, height=None, optimize=True): """Run the placement pipeline on a logical-structure ``tree``. Returns ``(nodes, groups, edges, root_bindings, cum_h, cum_v)`` where @@ -45,6 +45,10 @@ def build_layout(tree, x=None, y=None, width=None, height=None): ``tree`` is deep-copied, so the caller's dict is not mutated (aside from the order-optimization pass, which operates on the copy). + + ``optimize=False`` skips the order-optimization pre-pass. The tile-pool + reflow inside ``optimize_order`` uses this to score candidate arrangements + by real routing without recursing back into itself. """ tree = copy.deepcopy(tree) direction = tree.get("direction", "horizontal") @@ -52,7 +56,8 @@ def build_layout(tree, x=None, y=None, width=None, height=None): reverse = tree.get("reverse", False) # Optimize node order within groups to minimize edge crossings. - optimize_order(tree) + if optimize: + optimize_order(tree) def build_root(): root = {"id": "_root", From 16975f7229924831d67adb62b2e1309822491f77 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Thu, 9 Jul 2026 09:50:41 +0900 Subject: [PATCH 65/79] fix(layout): group-bus bundles per side, not one centroid side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _align_group_bus bundled ALL edges sharing a group endpoint onto a single box side chosen by the free-ends' centroid. When a group radiates in several directions at once — Stream Processing → Firehose (right), → OpenSearch (above), → CloudWatch (below) — the centroid landed slightly right, so the up/down edges were dragged out the right face and detoured around the box instead of leaving straight from the top/bottom edge. Now each edge is assigned to the box side its OWN free end faces, and only sides with 2+ edges are bus-bundled; an edge alone on a side keeps its natural perpendicular port. Stream Processing's three edges now exit top / bottom / right respectively as straight runs. realtime wire 1.533 → 1.116 (OpenSearch/CloudWatch straightened), 0 crossings/pierces preserved; network unchanged on every QA metric (2px coordinate shift only); other 10 diagrams byte-identical. 260 tests pass. --- skill/sdpm/layout/__init__.py | 108 +++++++++++++++++++--------------- 1 file changed, 59 insertions(+), 49 deletions(-) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 540f9efd..8413d590 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -2049,69 +2049,79 @@ def _align_group_bus(edges, nodes, groups): if e.get("_src_group"): bundles.setdefault((e["_src_group"], "src"), []).append(e) - for (gid, role), grp_edges in bundles.items(): - if len(grp_edges) < 2: + for (gid, role), all_grp_edges in bundles.items(): + if len(all_grp_edges) < 2: continue g = _find_group(groups, gid) if not g: continue gx, gy, gw, gh = g["x"], g["y"], g["width"], g["height"] + bcx, bcy = gx + gw / 2, gy + gh / 2 # The "free end" of each edge is the non-group end. def free_pt(e): return e["points"][0] if role == "dst" else e["points"][-1] - # Decide which box side faces the bundle: compare the free ends' - # centroid to the box center. - fxs = [free_pt(e)[0] for e in grp_edges] - fys = [free_pt(e)[1] for e in grp_edges] - cfx, cfy = sum(fxs) / len(fxs), sum(fys) / len(fys) - bcx, bcy = gx + gw / 2, gy + gh / 2 - dx, dy = cfx - bcx, cfy - bcy - if abs(dx) >= abs(dy): - side = "left" if dx < 0 else "right" - else: - side = "top" if dy < 0 else "bottom" - vertical_ports = side in ("left", "right") # ports vary along Y - - snapshot = [list(map(list, e["points"])) for e in edges] - before = _count_all_crossings(edges) - - # Order edges by their free end's coordinate along the port axis so - # adjacent ports connect to adjacent sources (no self-cross). - grp_edges.sort(key=lambda e: free_pt(e)[1] if vertical_ports else free_pt(e)[0]) - n = len(grp_edges) - # Box-edge anchor coordinates (the fixed coordinate of the port line). - bx = gx if side == "left" else (gx + gw) # used when vertical_ports - by = gy if side == "top" else (gy + gh) # used otherwise - - for rank, e in enumerate(grp_edges): - off = (rank - (n - 1) / 2) * _GROUP_BUS_PORT_GAP + # Assign each edge to the box side its OWN free end faces (not the + # bundle centroid). A group can radiate in several directions at once — + # e.g. Stream Processing → Firehose (right), → OpenSearch (above), + # → CloudWatch (below). Bundling all three onto one centroid side drags + # the up/down edges out the right face and makes them detour. Only edges + # that genuinely share a side should share a bus. + by_side = {} + for e in all_grp_edges: fp = free_pt(e) - # Nested lane: outer (farther from center) edges turn earlier so the - # bundle telescopes without crossing. - lane_depth = (n - rank) * _GROUP_BUS_LANE_GAP if role == "dst" else (rank + 1) * _GROUP_BUS_LANE_GAP - if vertical_ports: - py = round(bcy + off) - # Outer lanes turn farther from the box so the bundle telescopes. - lane = (gx - 20 - lane_depth) if side == "left" else (gx + gw + 20 + lane_depth) - port = [bx, py] - if role == "dst": - e["points"] = [fp, [lane, fp[1]], [lane, py], port] - else: - e["points"] = [port, [lane, py], [lane, fp[1]], fp] + dx, dy = fp[0] - bcx, fp[1] - bcy + if abs(dx) >= abs(dy): + e_side = "left" if dx < 0 else "right" else: - px = round(bcx + off) - lane = (gy - 20 - lane_depth) if side == "top" else (gy + gh + 20 + lane_depth) - port = [px, by] - if role == "dst": - e["points"] = [fp, [fp[0], lane], [px, lane], port] + e_side = "top" if dy < 0 else "bottom" + by_side.setdefault(e_side, []).append(e) + + for side, grp_edges in by_side.items(): + # A single edge on a side keeps its natural port — nothing to bundle. + if len(grp_edges) < 2: + continue + vertical_ports = side in ("left", "right") # ports vary along Y + + snapshot = [list(map(list, e["points"])) for e in edges] + before = _count_all_crossings(edges) + + # Order edges by their free end's coordinate along the port axis so + # adjacent ports connect to adjacent sources (no self-cross). + grp_edges.sort(key=lambda e: free_pt(e)[1] if vertical_ports else free_pt(e)[0]) + n = len(grp_edges) + # Box-edge anchor coordinates (the fixed coordinate of the port line). + bx = gx if side == "left" else (gx + gw) # used when vertical_ports + by = gy if side == "top" else (gy + gh) # used otherwise + + for rank, e in enumerate(grp_edges): + off = (rank - (n - 1) / 2) * _GROUP_BUS_PORT_GAP + fp = free_pt(e) + # Nested lane: outer (farther from center) edges turn earlier so + # the bundle telescopes without crossing. + lane_depth = (n - rank) * _GROUP_BUS_LANE_GAP if role == "dst" else (rank + 1) * _GROUP_BUS_LANE_GAP + if vertical_ports: + py = round(bcy + off) + # Outer lanes turn farther from the box so it telescopes. + lane = (gx - 20 - lane_depth) if side == "left" else (gx + gw + 20 + lane_depth) + port = [bx, py] + if role == "dst": + e["points"] = [fp, [lane, fp[1]], [lane, py], port] + else: + e["points"] = [port, [lane, py], [lane, fp[1]], fp] else: - e["points"] = [port, [px, lane], [fp[0], lane], fp] + px = round(bcx + off) + lane = (gy - 20 - lane_depth) if side == "top" else (gy + gh + 20 + lane_depth) + port = [px, by] + if role == "dst": + e["points"] = [fp, [fp[0], lane], [px, lane], port] + else: + e["points"] = [port, [px, lane], [fp[0], lane], fp] - if _count_all_crossings(edges) > before: - for e, pts in zip(edges, snapshot): - e["points"] = pts + if _count_all_crossings(edges) > before: + for e, pts in zip(edges, snapshot): + e["points"] = pts return edges From f6f0d73c25aadd52fd5b518b5d5248ec1dd2fc37 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Thu, 9 Jul 2026 13:14:26 +0900 Subject: [PATCH 66/79] fix(layout): builder crossing warning uses the QA detector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The builder's "Edges A→B and C→D cross" warning used a naive segment- intersection test (_segments_cross) that flagged every fan:merge shared trunk as a crossing — directly contradicting the QA metric, which excludes those structural T-junctions. Authors chasing the warning would restructure a diagram the QA harness already called clean (0 crossings). Extract _find_crossing_pairs as the single source of truth for "which edge pairs genuinely cross" (shared per-segment skip rules: collinear-trunk overlap and fan-trunk T-junctions). The builder warning now reports those distinct pairs; the QA metric _count_all_crossings keeps its segment-pair tally (the order/reflow/bend search was tuned against that magnitude, so it must NOT become a distinct-pair count). The two now agree on *whether* a pair crosses, differing only in how the total is tallied. realtime dropped 3 phantom "cross" warnings; across 16 test diagrams the builder warning now fires iff QA crossings > 0. All 12 golden diagrams render byte-identical (layout unchanged — this only touches warning text). 260 tests pass. --- skill/sdpm/layout/__init__.py | 72 +++++++++++++++++++++++++++++++++-- skill/sdpm/layout/render.py | 33 +++++----------- 2 files changed, 78 insertions(+), 27 deletions(-) diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 8413d590..9c445118 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -2662,8 +2662,12 @@ def _segments_cross(a1, a2, b1, b2): return False -def _count_all_crossings(edges): - """Count crossing pairs across all edges. +def _find_crossing_pairs(edges): + """Return the set of edge-index pairs (i, j) that genuinely cross. + + This is the single source of truth for "do two edges cross"; both the QA + metric (:func:`_count_all_crossings`, which just takes ``len``) and the + builder's human-readable warning consume it, so the two can never disagree. Two edges that SHARE an endpoint node (a fan-out from the same source or a fan-in to the same target) are allowed to run on top of each other on their @@ -2692,7 +2696,7 @@ def _count_all_crossings(edges): ys = [p[1] for p in pts] boxes.append((min(xs), min(ys), max(xs), max(ys))) - count = 0 + pairs = set() for i in range(len(edges)): pts_i = edges[i]["points"] if len(pts_i) < 2: @@ -2714,7 +2718,10 @@ def _count_all_crossings(edges): or ei.get("from") == ej.get("to") or ei.get("to") == ej.get("from") ) + found = False for si in range(len(pts_i) - 1): + if found: + break for sj in range(len(pts_j) - 1): if _segments_intersect(pts_i[si], pts_i[si + 1], pts_j[sj], pts_j[sj + 1]): if shares_endpoint: @@ -2734,6 +2741,65 @@ def _count_all_crossings(edges): ei, ej, pts_i, si, pts_j, sj ): continue + pairs.add((i, j)) + found = True + break + return pairs + + +def _count_all_crossings(edges): + """Count crossing SEGMENT-pairs across all edges. + + NOTE: this counts every crossing segment-pair, so two edges that cross at + several segments contribute more than one. That is deliberate — the whole + order/reflow/bend search was tuned against this magnitude, so it must stay a + segment count, NOT a distinct-edge-pair count. For the human-readable "which + edges cross" warning use :func:`_find_crossing_pairs` (distinct edge pairs); + both share the same per-segment skip rules so they never disagree on + *whether* a pair crosses, only on how the total is tallied.""" + boxes = [] + for e in edges: + pts = e["points"] + if len(pts) < 2: + boxes.append(None) + continue + xs = [p[0] for p in pts] + ys = [p[1] for p in pts] + boxes.append((min(xs), min(ys), max(xs), max(ys))) + + count = 0 + for i in range(len(edges)): + pts_i = edges[i]["points"] + if len(pts_i) < 2: + continue + ei = edges[i] + bi = boxes[i] + for j in range(i + 1, len(edges)): + pts_j = edges[j]["points"] + if len(pts_j) < 2: + continue + bj = boxes[j] + if bi[0] > bj[2] or bj[0] > bi[2] or bi[1] > bj[3] or bj[1] > bi[3]: + continue + ej = edges[j] + shares_endpoint = ( + ei.get("from") == ej.get("from") + or ei.get("to") == ej.get("to") + or ei.get("from") == ej.get("to") + or ei.get("to") == ej.get("from") + ) + for si in range(len(pts_i) - 1): + for sj in range(len(pts_j) - 1): + if _segments_intersect(pts_i[si], pts_i[si + 1], pts_j[sj], pts_j[sj + 1]): + if shares_endpoint: + if _segments_overlap_collinear( + pts_i[si], pts_i[si + 1], pts_j[sj], pts_j[sj + 1] + ): + continue + if _is_fan_trunk_t_junction( + ei, ej, pts_i, si, pts_j, sj + ): + continue count += 1 return count diff --git a/skill/sdpm/layout/render.py b/skill/sdpm/layout/render.py index 5fef9d57..6c10c145 100644 --- a/skill/sdpm/layout/render.py +++ b/skill/sdpm/layout/render.py @@ -17,13 +17,13 @@ import copy from . import ( + _find_crossing_pairs, _group_member_ids, _layout_collect, _layout_route_connections, _layout_scale, _layout_translate, _seg_crosses_box, - _segments_cross, box_to_elements, cancel_cross_axis_squash, measure_natural_child_sizes, @@ -345,29 +345,14 @@ def _build_warnings(nodes_out, groups_out, edges_out, rb, crossing_reported.add(report_key) # Check edge-edge crossings (segment intersection). - edge_crossing_reported = set() - for i in range(len(edges_out)): - pts_i = edges_out[i]["points"] - if len(pts_i) < 2: - continue - for j in range(i + 1, len(edges_out)): - pts_j = edges_out[j]["points"] - if len(pts_j) < 2: - continue - crossed = False - for si in range(len(pts_i) - 1): - if crossed: - break - for sj in range(len(pts_j) - 1): - if _segments_cross(pts_i[si], pts_i[si + 1], pts_j[sj], pts_j[sj + 1]): - key_ij = (min(i, j), max(i, j)) - if key_ij not in edge_crossing_reported: - e_i = f"{edges_out[i]['from']}→{edges_out[i]['to']}" - e_j = f"{edges_out[j]['from']}→{edges_out[j]['to']}" - warnings.append(f"Edges {e_i} and {e_j} cross.") - edge_crossing_reported.add(key_ij) - crossed = True - break + # Use the SAME crossing detector the QA metric uses, so a fan-merge trunk's + # structural T-junction (spokes peeling off a shared trunk) is not reported + # as a crossing. Previously the builder used a naive segment-intersection + # test that flagged every shared trunk, contradicting the QA "crossings=0". + for i, j in sorted(_find_crossing_pairs(edges_out)): + e_i = f"{edges_out[i]['from']}→{edges_out[i]['to']}" + e_j = f"{edges_out[j]['from']}→{edges_out[j]['to']}" + warnings.append(f"Edges {e_i} and {e_j} cross.") # Group-frame pierce: an edge slices through a framed group's box without # connecting to that group or any icon inside it. The engine auto-detours From 96935cba81e759b16779c82e0e176ada63950f19 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Thu, 9 Jul 2026 13:14:39 +0900 Subject: [PATCH 67/79] docs(layout): correct crossing-warning note, add preview step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The guide said the builder's edge-crossing warning is "conservative and may flag a shared fan trunk that is not a real crossing." That is no longer true (the warning now uses the QA detector) — rewrite it: the warning fires iff the QA crossings metric is > 0, and a tall/wide-group hint is advisory (if overflow is 0 the layout fits, don't restructure). - Add a "Preview the rendered image" step to How to run it: metrics catch geometry, but only the eye catches a wrong label or awkward bend. Shows the one-slide include deck + preview command (a gap a subagent hit). --- skill/references/guides/arch-layout-engine.md | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/skill/references/guides/arch-layout-engine.md b/skill/references/guides/arch-layout-engine.md index a9ab440c..4cde3ca2 100644 --- a/skill/references/guides/arch-layout-engine.md +++ b/skill/references/guides/arch-layout-engine.md @@ -49,6 +49,16 @@ python3 scripts/pptx_builder.py layout input.json \ height args (both front-ends). - The engine scales the whole diagram to fit the target box, so author at any scale — relationships matter, absolute sizes don't. +- **Preview the rendered image** (the metrics catch geometry, but only your eyes + catch a wrong-looking label or an awkward bend). Write the elements to a file, + reference them from a one-slide deck, and render it: + + ```bash + # includes/diagram.json = the engine's elements output + # slides/diagram.json = {"layout":"Title Only","elements":[{"type":"include","src":"includes/diagram.json"}]} + # deck.json = {"template":"blank-dark.pptx", "fonts":{...}, "defaultTextColor":"#FFFFFF"} + uv run python3 scripts/pptx_builder.py preview + ``` --- @@ -309,9 +319,12 @@ framed groups do. fix it. You have the JSON and the rendered image; decide the structural fix yourself (connect to a group box, `fan: "merge"`, reorder, change `direction`, …) using the techniques above. Size warnings (tall/wide group, - overflow, compressed spacing) still carry a short hint. The builder's - edge-crossing warning is conservative and may flag a shared fan trunk that is - *not* a real crossing — trust the diagram and the QA metric over that one. + overflow, compressed spacing) still carry a short hint. The edge-crossing + warning uses the **same** detector as the QA `crossings` metric, so a + `fan: "merge"` trunk's structural T-junction is NOT reported — if `warnings` + lists an `Edges … cross`, the QA metric will show `crossings > 0` too. A + "tall/wide group" hint is advisory only: if `overflow` is 0 the layout fits, + so don't restructure just to silence it. - `bbox`: final bounding box after scale-to-fit. - `metrics`: objective QA numbers, returned inline by the `arch_diagram` MCP tool. For the CLI, get the same numbers from the QA harness: @@ -333,4 +346,4 @@ framed groups do. coordinates) → re-render, until the three defect counts are 0. --- -**Updated**: 2026-07-08 +**Updated**: 2026-07-09 From 8013548bf9715ad9827e41f5b5d9b7cba5445ccc Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Thu, 9 Jul 2026 13:17:33 +0900 Subject: [PATCH 68/79] docs(layout): defer preview to the review step, not a separate pass The slide flow's review step (create-new-3-review) already previews every slide as a PNG and treats it as the source of truth, so a dedicated preview instruction in this guide was redundant. Point at that step instead, and keep the one-slide-deck preview only as the out-of-flow fallback (when the arch_diagram tool is used standalone). --- skill/references/guides/arch-layout-engine.md | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/skill/references/guides/arch-layout-engine.md b/skill/references/guides/arch-layout-engine.md index 4cde3ca2..4263f577 100644 --- a/skill/references/guides/arch-layout-engine.md +++ b/skill/references/guides/arch-layout-engine.md @@ -49,16 +49,11 @@ python3 scripts/pptx_builder.py layout input.json \ height args (both front-ends). - The engine scales the whole diagram to fit the target box, so author at any scale — relationships matter, absolute sizes don't. -- **Preview the rendered image** (the metrics catch geometry, but only your eyes - catch a wrong-looking label or an awkward bend). Write the elements to a file, - reference them from a one-slide deck, and render it: - - ```bash - # includes/diagram.json = the engine's elements output - # slides/diagram.json = {"layout":"Title Only","elements":[{"type":"include","src":"includes/diagram.json"}]} - # deck.json = {"template":"blank-dark.pptx", "fonts":{...}, "defaultTextColor":"#FFFFFF"} - uv run python3 scripts/pptx_builder.py preview - ``` +- The `metrics`/`warnings` catch geometry, but a wrong-looking label or awkward + bend only shows in the render. In the normal slide flow the review step + (`create-new-3-review`) previews every slide as a PNG, so the diagram gets + eyeballed there — no separate preview needed. Outside that flow, drop the + elements into a one-slide deck and run `pptx_builder.py preview `. --- From 82af54de3ac96f023503cbc558d2a9b23b10b70b Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Thu, 9 Jul 2026 14:43:55 +0900 Subject: [PATCH 69/79] fix(mcp): preview_files empty for <10-page decks (pdftoppm naming) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_python(save=True, measure_slides=[...]) returned "preview_files": [] with no error — the core render→eyeball→fix loop had no PNGs. pdftoppm zero-pads the page number to the width of the largest index, so a 1-9 page export is "page-1.png" (no padding), but the code only probed widths 6/2/3, never 1 — so every lookup missed and the list came back empty, silently. Probe widths 1-6, and when a slug still has no PNG, set preview_error with the slugs and the filenames pdftoppm actually produced, so the gap is visible instead of an empty list. Verified: a 2-slide deck now returns both PNGs. --- mcp-local/sandbox_tools.py | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/mcp-local/sandbox_tools.py b/mcp-local/sandbox_tools.py index 25ec75d6..4e79e101 100644 --- a/mcp-local/sandbox_tools.py +++ b/mcp-local/sandbox_tools.py @@ -369,18 +369,36 @@ def _fp(c: dict) -> str: cmd_png = ["pdftoppm", "-png", "-scale-to", "1280", str(pdf_path), str(png_outdir / "page")] subprocess.run(cmd_png, capture_output=True, text=True, stdin=subprocess.DEVNULL) + # pdftoppm zero-pads the page number to the width of + # the largest page index, so a 1-9 page export is + # "page-1.png" (no padding), 10-99 is "page-01.png", + # etc. Try every plausible width instead of assuming + # one — the old code only tried 6/2/3 and silently + # produced an empty preview list for <10-page decks. filtered_previews = [] + missing = [] for idx_p, slug in enumerate(pptx_slugs): - src_png = png_outdir / f"page-{idx_p + 1:06d}.png" - if not src_png.exists(): - src_png = png_outdir / f"page-{idx_p + 1:02d}.png" - if not src_png.exists(): - src_png = png_outdir / f"page-{idx_p + 1:03d}.png" - if src_png.exists(): + n_p = idx_p + 1 + src_png = None + for width in (1, 2, 3, 4, 6): + cand = png_outdir / f"page-{n_p:0{width}d}.png" + if cand.exists(): + src_png = cand + break + if src_png: dst = preview_dir / f"{slug}.png" shutil.copy2(src_png, dst) filtered_previews.append(str(dst)) + else: + missing.append(slug) result["preview_files"] = filtered_previews + if missing: + # Surface the silent gap instead of returning []. + produced = sorted(p.name for p in png_outdir.glob("*.png")) + result["preview_error"] = ( + f"No PNG matched slugs {missing}; " + f"pdftoppm produced {produced}" + ) except Exception as e: result["preview_error"] = str(e) From 3295e70d7d1b636a2ef87d48bba3c7dbcbda77a3 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Thu, 9 Jul 2026 14:44:07 +0900 Subject: [PATCH 70/79] fix(build): warn when an include is missing or empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The builder silently skips an {"type":"include","src":...} whose file is absent (the `if include_path.exists():` has no else), so a whole diagram could vanish from a slide with no error or warning — forcing manual inline-expansion to even find the cause. Add check_includes (run in _resolve_config alongside the font-size and overlay checks): for every include element, warn if src is empty, the file is missing, the JSON is invalid, or it expands to 0 elements. Verified on a deck with a missing include and an empty include — both now surface as warnings naming the slide, element index, and resolved path. --- skill/sdpm/api.py | 6 +++- skill/sdpm/checks/__init__.py | 3 +- skill/sdpm/checks/includes.py | 64 +++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 skill/sdpm/checks/includes.py diff --git a/skill/sdpm/api.py b/skill/sdpm/api.py index e70c04c4..e21b7f8a 100644 --- a/skill/sdpm/api.py +++ b/skill/sdpm/api.py @@ -460,7 +460,7 @@ def _resolve_config( raise ValueError(f"Missing assets ({len(missing)}): {', '.join(sorted(missing)[:10])}") # Token discipline: fontSize must come from --fs-* tokens in active style - from sdpm.checks import check_font_size_tokens, check_overlay_textbox + from sdpm.checks import check_font_size_tokens, check_includes, check_overlay_textbox fs_warnings = check_font_size_tokens(data, input_path) warnings.extend(fs_warnings) @@ -469,6 +469,10 @@ def _resolve_config( overlay_warnings = check_overlay_textbox(data) warnings.extend(overlay_warnings) + # Include references: a missing/empty include silently drops its content. + include_warnings = check_includes(data, base_dir) + warnings.extend(include_warnings) + # Resolve overrides slides = data.get("slides", []) id_map = {} diff --git a/skill/sdpm/checks/__init__.py b/skill/sdpm/checks/__init__.py index 45e7eba3..5d72f19f 100644 --- a/skill/sdpm/checks/__init__.py +++ b/skill/sdpm/checks/__init__.py @@ -1,6 +1,7 @@ """Build-time checks for slide JSON (token discipline, etc.).""" from sdpm.checks.font_size import check_font_size_tokens +from sdpm.checks.includes import check_includes from sdpm.checks.overlay_textbox import check_overlay_textbox -__all__ = ["check_font_size_tokens", "check_overlay_textbox"] +__all__ = ["check_font_size_tokens", "check_includes", "check_overlay_textbox"] diff --git a/skill/sdpm/checks/includes.py b/skill/sdpm/checks/includes.py new file mode 100644 index 00000000..4a673bf8 --- /dev/null +++ b/skill/sdpm/checks/includes.py @@ -0,0 +1,64 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +"""Validate `{"type": "include", "src": ...}` references before a build. + +The builder silently skips an include whose file is missing or empty, so a +whole diagram can vanish from a slide with no error. This check surfaces that +as a warning at config-resolution time. +""" + +import json +from pathlib import Path + + +def check_includes(slides_data: dict, base_dir: Path) -> list[str]: + """Check every include element's src resolves to a non-empty element list. + + Returns a list of warning lines (empty if all includes are fine). ``src`` is + resolved relative to ``base_dir`` (the deck directory), matching the + builder's own resolution. + """ + slides = slides_data.get("slides", []) + findings: list[str] = [] + + for slide_idx, slide in enumerate(slides, start=1): + elements = slide.get("elements") or [] + if not isinstance(elements, list): + continue + slug = slide.get("id", "") + location = f"page{slide_idx:02d}({slug})" if slug else f"page{slide_idx:02d}" + for e_idx, elem in enumerate(elements): + if not isinstance(elem, dict) or elem.get("type") != "include": + continue + src = elem.get("src", "") + if not src: + findings.append(f" {location} element[{e_idx}]: include has no `src`.") + continue + path = Path(src) if Path(src).is_absolute() else base_dir / src + if not path.exists(): + findings.append( + f" {location} element[{e_idx}]: include src \"{src}\" not found " + f"(resolved to {path}) — the referenced content will be MISSING." + ) + continue + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, ValueError) as exc: + findings.append( + f" {location} element[{e_idx}]: include src \"{src}\" is invalid JSON ({exc})." + ) + continue + inc_elements = data if isinstance(data, list) else data.get("elements", []) + if not inc_elements: + findings.append( + f" {location} element[{e_idx}]: include src \"{src}\" expands to 0 " + f"elements — expected a non-empty array or {{\"elements\": [...]}}." + ) + + if not findings: + return [] + header = ( + f"Include problems ({len(findings)}): a referenced file is missing/empty, " + f"so its content will silently NOT render. Fix the src path or the file." + ) + return [header, *findings] From 4aedb31bc3e4cbc943c2bc4be9f4eab903b0ed06 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Thu, 9 Jul 2026 14:44:18 +0900 Subject: [PATCH 71/79] fix(build): icon labels no longer wrap one glyph per line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An icon label was rendered in a textbox the width of the icon (~60px) with word_wrap off, so a caption like "Cognito" broke into "Co / gni / to" — every arch_diagram icon label came out as a vertical stack of glyphs, and the deck had to be rebuilt by hand with wide textboxes. Size the bottom label box to the longest label line (~0.85em/char + pad) with a floor of the icon width, and center it on the icon so the caption stays one line and overhangs evenly. Verified on the e-commerce diagram: "Cognito", "DynamoDB", "CloudFront", "ElastiCache" etc. dropped from 4-line glyph stacks to 1-2 readable lines. Labels longer than the icon width still overhang harmlessly (centered, engine leaves margin). --- skill/sdpm/builder/elements/image.py | 31 +++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/skill/sdpm/builder/elements/image.py b/skill/sdpm/builder/elements/image.py index ad40c363..b06ec3ec 100644 --- a/skill/sdpm/builder/elements/image.py +++ b/skill/sdpm/builder/elements/image.py @@ -9,11 +9,20 @@ from sdpm.utils.image import resolve_image_path, apply_image_effects from sdpm.utils.effects import apply_effects from sdpm.utils.svg import _recolor_svg, get_svg_dimensions, generate_qr_svg, add_svg_to_slide -from sdpm.utils.text import _expand_styled_newlines +from sdpm.utils.text import _expand_styled_newlines, parse_styled_text from sdpm.assets import is_recolor_protected _DEFAULTS = ELEMENT_DEFAULTS["image"] + +def _plain_label_text(line: str) -> str: + """Return a label line's visible text with any {{...:text}} style markup + stripped, for width estimation.""" + try: + return "".join(seg.get("text", "") for seg in parse_styled_text(line)) + except Exception: + return line + class ImageMixin: """Mixin providing image element methods.""" @@ -241,9 +250,25 @@ def _add_image(self, slide, elem): # Scale margin proportionally to icon size (base: 4% of height) label_margin = int(height * 0.04) if label_pos == "bottom": - lbl_x = x + # The label box must be WIDER than the icon, or a caption like + # "Cognito" wraps one glyph per line ("Co / gni / to") inside a + # 60px icon-width box even with word_wrap off. Size the box to the + # longest label line and center it on the icon, so the caption + # stays a single readable line that overhangs the icon evenly. + _lbl_lines = _expand_styled_newlines(label.replace("\\n", "\n")).split("\n") + _max_chars = max( + (len(_plain_label_text(ln)) for ln in _lbl_lines), default=0) + # Width per char at the label font size. PowerPoint renders wider + # than a naive em estimate (kerning + internal box margins), so + # use ~0.85em and pad generously — a box even slightly too narrow + # wraps mid-word. Overhang past the icon is harmless (labels are + # centered and the engine leaves margin); a too-narrow box is not. + _text_w_px = int(_max_chars * label_size * 0.85) + 16 + _icon_w_px = width_pct or 60 + lbl_w_px = max(_icon_w_px, _text_w_px) + lbl_w = self._px_to_emu(lbl_w_px) + lbl_x = x + width // 2 - lbl_w // 2 lbl_y = y + height + label_margin - lbl_w = width elif label_pos == "right": lbl_x = x + width + label_margin * 2 lbl_y = y + height // 3 From dd6d6f03994f12d60d827a79ca0457fd98788fbd Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Thu, 9 Jul 2026 15:12:34 +0900 Subject: [PATCH 72/79] docs(workflow): route architecture diagrams to the layout engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compose step named the chart guides ("when a slide has a chart, read guides chart-bar/…") but said nothing about architecture diagrams, so an agent building one had no push toward guides arch-layout-engine / the arch_diagram tool — it could hand-place icon and arrow coordinates and end up with avoidable crossings (exactly what happened to a hand-built deck whose viewer fan-in edges crossed, which the engine routes cleanly). Add an architecture/system/flow-diagram clause to the same Reminder: read arch-layout-engine and build with the layout engine (arch_diagram MCP tool or pptx_builder.py layout), falling back to hand-placement (arch-elements) only for fine-tuning or non-flow art. --- skill/references/workflows/create-new-2-compose.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skill/references/workflows/create-new-2-compose.md b/skill/references/workflows/create-new-2-compose.md index 591634e9..c59e1700 100644 --- a/skill/references/workflows/create-new-2-compose.md +++ b/skill/references/workflows/create-new-2-compose.md @@ -34,7 +34,7 @@ uv run python3 scripts/pptx_builder.py examples components/all uv run python3 scripts/pptx_builder.py examples patterns ``` -**Reminder:** Read relevant guides as needed. When a slide contains a chart, read the corresponding guide (`guides chart-bar`, `guides chart-line`, or `guides chart-pie`) before building elements. +**Reminder:** Read relevant guides as needed before building elements. When a slide contains a chart, read the corresponding guide (`guides chart-bar`, `guides chart-line`, or `guides chart-pie`). When a slide is an **architecture / system / flow diagram** (anything you'd describe as "what connects to what"), read `guides arch-layout-engine` and build it with the layout engine (the `arch_diagram` MCP tool, or `pptx_builder.py layout`) — it auto-routes the arrows and minimizes crossings, so you never hand-place icon/arrow coordinates. Only fall back to hand-placement (`guides arch-elements`) for fine-tuning or non-flow art. Slides that share a label prefix in the outline share a visual base — use override (inheritance) to build them. The base slide carries the common elements; each derived slide adds or highlights its part. Slide transitions between them create animation effects. From 81fc3fc252380fb218ef68a802b88d4d22a0b06a Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Thu, 9 Jul 2026 15:26:06 +0900 Subject: [PATCH 73/79] style(layout): remove unused imports and dead locals (ruff clean) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The branch carried 7 ruff findings (F401 unused imports, F841 unused locals) accumulated across the engine work — origin/main is clean, so these would trip a lint gate on the PR. Remove them: the `score`/`copy` imports and the `v_sides`/`is_fanout_edge`/`new_h_y`/`lid`/`orig` locals were all unreferenced. Pure dead-code removal — 260 tests pass, demo diagrams route identically (0 crossings/pierces). --- skill/scripts/layout_search.py | 3 +-- skill/sdpm/layout/__init__.py | 5 ----- skill/sdpm/layout/graph.py | 1 - 3 files changed, 1 insertion(+), 8 deletions(-) diff --git a/skill/scripts/layout_search.py b/skill/scripts/layout_search.py index 92a2567a..d231a88e 100644 --- a/skill/scripts/layout_search.py +++ b/skill/scripts/layout_search.py @@ -27,7 +27,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) -from layout_qa import measure, score # noqa: E402 +from layout_qa import measure # noqa: E402 _DIRECTIONS = ("horizontal", "vertical") _ALIGNS = ("start", "center", "end") @@ -162,7 +162,6 @@ def main(): cg = _collect_groups(cand) for g, d in zip(cg, dirs): gid = g.get("id", "_root") - orig = base_m and g.get("direction") print(f" {gid}: {d}") best = top[0] diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 9c445118..6f6d3984 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -1531,7 +1531,6 @@ def _layout_route_connections(connections, nodes, groups=None): # For non-fan-out, only apply if same axis (horizontal↔horizontal # or vertical↔vertical) to avoid bad routes. h_sides = {"left", "right"} - v_sides = {"top", "bottom"} natural_axis = "h" if src_side in h_sides else "v" decided_axis = "h" if decided in h_sides else "v" # Never apply the decided side if the target lies on the OPPOSITE @@ -1680,7 +1679,6 @@ def _layout_route_connections(connections, nodes, groups=None): src_is_grp = _find_node(nodes, conn["from"]) is None dst_is_grp = _find_node(nodes, conn["to"]) is None - is_fanout_edge = False if i in reverse_set: src_node = _find_node(nodes, conn["from"]) dst_node = _find_node(nodes, conn["to"]) @@ -2564,8 +2562,6 @@ def _spread_overlapping_bends(edges, conn_sides, connections): Phase 1: Resolve all crossings by searching for optimal bend shifts. Phase 2: Separate bends that are too close (even if not crossing). """ - import copy - # Phase 1: resolve crossings for _iteration in range(_MAX_RESOLVE_ITERATIONS): crossing = _find_first_crossing(edges) @@ -3967,7 +3963,6 @@ def _separate_close_horizontal_segments(edges): new_crossings = _count_all_crossings(test_edges) # Recalculate overlap new_pts = test_edges[ei]["points"] - new_h_y = None for nk in range(len(new_pts) - 1): if new_pts[nk][1] == new_pts[nk + 1][1] and abs(new_pts[nk][0] - new_pts[nk + 1][0]) > 20: new_xmin = min(new_pts[nk][0], new_pts[nk + 1][0]) diff --git a/skill/sdpm/layout/graph.py b/skill/sdpm/layout/graph.py index 9d5e32a9..e9db9d43 100644 --- a/skill/sdpm/layout/graph.py +++ b/skill/sdpm/layout/graph.py @@ -246,7 +246,6 @@ def _shift_node_x(pos, nid, new_x, x_locked, list_groups): """Move a node's X. If it's in an h_list, shift the whole list.""" if nid in x_locked: # Find the list group and shift all members - lid = x_locked[nid] delta = new_x - pos[nid][0] for axis, nodes, gap in list_groups: if axis == "x" and nid in nodes: From 7bba8e605bb3d9369ffb455533538eaaf8cd6c55 Mon Sep 17 00:00:00 2001 From: Naohito Yoshikawa Date: Thu, 9 Jul 2026 17:15:39 +0900 Subject: [PATCH 74/79] docs(layout): fan:merge tidies any shared-hub bundle, not just same-purpose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guide framed fan:merge as strictly for "same purpose" edges, so an agent would leave several differently-labeled arrows sharing one hub (e.g. a relay that put→DynamoDB, publish→AppSync, invoke→Runtime; or replay/broadcast/ restore all entering one viewer) as a loose spray, even though merging them into a single trunk that splits near the far ends reads much cleaner and the per-spoke labels still render. Reframe technique 2 and the anti-pattern: merge bundles edges that SHARE A HUB node (fan-out from one source or fan-in to one target) regardless of purpose; the only thing that must not merge is edges that merely cross paths without a shared endpoint. Self-protection (roll back a trunk that would pierce an icon) is unchanged. --- skill/references/guides/arch-layout-engine.md | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/skill/references/guides/arch-layout-engine.md b/skill/references/guides/arch-layout-engine.md index 4263f577..895a38b9 100644 --- a/skill/references/guides/arch-layout-engine.md +++ b/skill/references/guides/arch-layout-engine.md @@ -155,11 +155,11 @@ This single change took the three-tier observed diagram from **21 crossings to 0**. Use it whenever a set of sources all talk to "the data tier" / "the observability stack" / "the services layer" as a unit. -### 2. Fan merge — bundle same-purpose arrows onto one trunk +### 2. Fan merge — bundle arrows that share a hub onto one trunk -When several edges share a source (fan-out) or a target (fan-in) and represent -the *same purpose*, set `"fan": "merge"` on each. They unify onto one port and a -shared trunk, then split near the spokes: +When several edges share a source (fan-out) or a target (fan-in) — whether or +not they carry the same purpose — set `"fan": "merge"` on each. They unify onto +one port and a shared trunk, then split near the spokes: ```json {"from": "stepfn", "to": "lambda1", "fan": "merge"}, @@ -175,9 +175,17 @@ shared trunk, then split near the spokes: cleaned up later, so it refuses to create one that cuts through an icon). → You can safely mark a bundle `merge`; a geometrically-impossible merge just won't happen. It is NOT a way to force a bad trunk. -- Don't blanket-apply `merge` to everything. Mark bundles that are genuinely "the - same flow" (a fan-out to workers, a fan-in to a queue). Unrelated edges sharing - an endpoint shouldn't merge. +- Don't blanket-apply `merge` to everything. The clearest case is a genuine + "same flow" bundle (a fan-out to workers, a fan-in to a queue). But `merge` + also **tidies any group of edges that share one hub** — a single node that + fans out to several services, or several sources that all converge on one + node — even when the edges carry *different* labels/purposes. If N arrows + leave or enter the same icon and read as a loose spray, merging them makes + that icon emit/receive **one clean trunk** that only splits near the far + ends. Prefer merging such a hub bundle; the per-edge labels still render on + each spoke, so the distinct purposes stay legible. + - The exception is edges that merely *cross paths* without sharing an + endpoint — those aren't a bundle and shouldn't be merged. ### 3. Branch nodes go perpendicular (keep the main line straight) @@ -261,8 +269,9 @@ element). If you need the cluster named, keep a `groupType`. ## Anti-patterns - **Wiring every icon to every icon** across two groups → use a group endpoint. -- **`fan: "merge"` on unrelated edges** that merely share an endpoint → only - bundle same-purpose flows. +- **`fan: "merge"` on edges that DON'T share an endpoint** (two arrows that + merely cross paths) → merge only bundles edges sharing one hub node. Edges + sharing a hub but carrying different purposes CAN merge (see technique 2). - **Forcing a merge the engine rolled back** → the trunk pierced an icon; restructure (split the bundle, reorder, or route to a group) instead. - **Deep vertical nesting** (5+ stacked tiers) → overflows height and compresses From 01bd119611d3d86558316e8290c8946a1cf66907 Mon Sep 17 00:00:00 2001 From: ShotaroKataoka Date: Mon, 13 Jul 2026 16:07:39 +0900 Subject: [PATCH 75/79] chore(layout): add missing MIT-0 license header to graph.py --- skill/sdpm/layout/graph.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/skill/sdpm/layout/graph.py b/skill/sdpm/layout/graph.py index e9db9d43..45917e95 100644 --- a/skill/sdpm/layout/graph.py +++ b/skill/sdpm/layout/graph.py @@ -1,3 +1,5 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 """Graph layout engine: constraint-based placement for non-tree diagrams. Input format: From bb4ccf641fe3f16682dddda5ebd6b59c7b550fea Mon Sep 17 00:00:00 2001 From: ShotaroKataoka Date: Mon, 13 Jul 2026 16:07:39 +0900 Subject: [PATCH 76/79] fix(builder): account for CJK glyph width in icon label sizing The bottom-label width estimate counted glyphs at a flat 0.85em, which under-sizes Japanese/CJK labels (fullwidth glyphs are square) and re-introduces the one-glyph-per-line wrapping the box is sized to prevent. Weight East Asian wide/fullwidth glyphs at 1.05em instead. --- skill/sdpm/builder/elements/image.py | 33 +++++++++++++++++++++------- tests/test_image_label_width.py | 33 ++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 8 deletions(-) create mode 100644 tests/test_image_label_width.py diff --git a/skill/sdpm/builder/elements/image.py b/skill/sdpm/builder/elements/image.py index b06ec3ec..01a201bf 100644 --- a/skill/sdpm/builder/elements/image.py +++ b/skill/sdpm/builder/elements/image.py @@ -23,6 +23,21 @@ def _plain_label_text(line: str) -> str: except Exception: return line + +def _label_line_em(line: str) -> float: + """Return a label line's estimated display width in em at the label font + size. Latin/half-width glyphs count as 0.85em (calibrated against + PowerPoint's rendering, see the call site); East Asian wide/fullwidth + glyphs (CJK) are square, so they count as 1.05em — a plain character + count would under-size Japanese labels and re-introduce the one-glyph- + per-line wrapping this box is sized to prevent. Style markup + ({{...:text}}) is stripped first.""" + import unicodedata + + text = _plain_label_text(line) + return sum(1.05 if unicodedata.east_asian_width(ch) in ("W", "F") else 0.85 + for ch in text) + class ImageMixin: """Mixin providing image element methods.""" @@ -256,14 +271,16 @@ def _add_image(self, slide, elem): # longest label line and center it on the icon, so the caption # stays a single readable line that overhangs the icon evenly. _lbl_lines = _expand_styled_newlines(label.replace("\\n", "\n")).split("\n") - _max_chars = max( - (len(_plain_label_text(ln)) for ln in _lbl_lines), default=0) - # Width per char at the label font size. PowerPoint renders wider - # than a naive em estimate (kerning + internal box margins), so - # use ~0.85em and pad generously — a box even slightly too narrow - # wraps mid-word. Overhang past the icon is harmless (labels are - # centered and the engine leaves margin); a too-narrow box is not. - _text_w_px = int(_max_chars * label_size * 0.85) + 16 + _max_em = max( + (_label_line_em(ln) for ln in _lbl_lines), default=0.0) + # Width per glyph at the label font size. PowerPoint renders + # wider than a naive em estimate (kerning + internal box + # margins), so _label_line_em uses ~0.85em for Latin and + # ~1.05em for CJK fullwidth glyphs, plus generous padding — + # a box even slightly too narrow wraps mid-word. Overhang past + # the icon is harmless (labels are centered and the engine + # leaves margin); a too-narrow box is not. + _text_w_px = int(_max_em * label_size) + 16 _icon_w_px = width_pct or 60 lbl_w_px = max(_icon_w_px, _text_w_px) lbl_w = self._px_to_emu(lbl_w_px) diff --git a/tests/test_image_label_width.py b/tests/test_image_label_width.py new file mode 100644 index 00000000..feb8b58f --- /dev/null +++ b/tests/test_image_label_width.py @@ -0,0 +1,33 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +"""Tests for icon label width estimation in sdpm.builder.elements.image.""" + +from __future__ import annotations + +from sdpm.builder.elements.image import _label_line_em, _plain_label_text + + +def test_plain_text_strips_style_markup(): + assert _plain_label_text("{{bold:Cognito}}") == "Cognito" + + +def test_latin_label_width(): + # 7 Latin glyphs at 0.85em each. + assert _label_line_em("Cognito") == 7 * 0.85 + + +def test_cjk_label_wider_than_same_length_latin(): + # Same glyph count, but CJK fullwidth glyphs are square (~1.05em), + # so the Japanese label must measure wider than the Latin one. + assert _label_line_em("認証基盤サービス") > _label_line_em("Cognito!") + + +def test_mixed_label_width(): + # parse_styled_text inserts a space at the CJK/Latin boundary, so + # "監視DNA" renders as "監視 DNA": 2 CJK (1.05 each) + 4 Latin-width + # glyphs (0.85 each, including the inserted space). + assert _label_line_em("監視DNA") == 2 * 1.05 + 4 * 0.85 + + +def test_empty_label(): + assert _label_line_em("") == 0 From 61c946a1e93d822c7b08e8097e435ee27b2dfbc0 Mon Sep 17 00:00:00 2001 From: ShotaroKataoka Date: Mon, 13 Jul 2026 16:07:40 +0900 Subject: [PATCH 77/79] test(layout): add coverage for render pipeline, metrics, and include check - render_architecture: result shape, clean-chain metrics, element types, include_metrics toggle, input immutability, targetArea override - build_layout: scale-to-fit and edge collection - metrics: crossing detection on a known X, score ordering - check_includes: all warning branches (missing src/file, invalid JSON, empty expansion, absolute path, page location) --- tests/test_checks_includes.py | 78 +++++++++++++++++++ tests/test_layout_render.py | 137 ++++++++++++++++++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 tests/test_checks_includes.py create mode 100644 tests/test_layout_render.py diff --git a/tests/test_checks_includes.py b/tests/test_checks_includes.py new file mode 100644 index 00000000..7bc3fc6b --- /dev/null +++ b/tests/test_checks_includes.py @@ -0,0 +1,78 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +"""Tests for sdpm.checks.includes.""" + +from __future__ import annotations + +import json + +from sdpm.checks.includes import check_includes + + +def _slides(elements): + return {"slides": [{"id": "s1", "elements": elements}]} + + +def test_no_includes_returns_empty(tmp_path): + data = _slides([{"type": "text", "text": "hello"}]) + assert check_includes(data, tmp_path) == [] + + +def test_valid_include_passes(tmp_path): + inc = tmp_path / "diagram.json" + inc.write_text(json.dumps([{"type": "text", "text": "x"}]), encoding="utf-8") + data = _slides([{"type": "include", "src": "diagram.json"}]) + assert check_includes(data, tmp_path) == [] + + +def test_valid_include_elements_dict_passes(tmp_path): + inc = tmp_path / "diagram.json" + inc.write_text(json.dumps({"elements": [{"type": "text"}]}), encoding="utf-8") + data = _slides([{"type": "include", "src": "diagram.json"}]) + assert check_includes(data, tmp_path) == [] + + +def test_missing_src_key_warns(tmp_path): + data = _slides([{"type": "include"}]) + warnings = check_includes(data, tmp_path) + assert warnings + assert "no `src`" in "\n".join(warnings) + + +def test_missing_file_warns(tmp_path): + data = _slides([{"type": "include", "src": "nope.json"}]) + warnings = check_includes(data, tmp_path) + assert warnings + joined = "\n".join(warnings) + assert "not found" in joined + assert "nope.json" in joined + + +def test_invalid_json_warns(tmp_path): + inc = tmp_path / "broken.json" + inc.write_text("{not json", encoding="utf-8") + data = _slides([{"type": "include", "src": "broken.json"}]) + warnings = check_includes(data, tmp_path) + assert "invalid JSON" in "\n".join(warnings) + + +def test_empty_elements_warns(tmp_path): + inc = tmp_path / "empty.json" + inc.write_text("[]", encoding="utf-8") + data = _slides([{"type": "include", "src": "empty.json"}]) + warnings = check_includes(data, tmp_path) + assert "0 " in "\n".join(warnings) + + +def test_absolute_src_resolves(tmp_path): + inc = tmp_path / "abs.json" + inc.write_text(json.dumps([{"type": "text"}]), encoding="utf-8") + data = _slides([{"type": "include", "src": str(inc)}]) + # base_dir intentionally different from the file's directory + assert check_includes(data, tmp_path / "elsewhere") == [] + + +def test_warning_includes_page_location(tmp_path): + data = _slides([{"type": "include", "src": "gone.json"}]) + warnings = check_includes(data, tmp_path) + assert "page01(s1)" in "\n".join(warnings) diff --git a/tests/test_layout_render.py b/tests/test_layout_render.py new file mode 100644 index 00000000..9b296daf --- /dev/null +++ b/tests/test_layout_render.py @@ -0,0 +1,137 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +"""Tests for sdpm.layout.render and sdpm.layout.metrics.""" + +from __future__ import annotations + +import copy + +from sdpm.layout.metrics import measure, measure_layout, score +from sdpm.layout.render import build_layout, render_architecture + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +_SIMPLE_CHAIN = { + "direction": "horizontal", + "children": [ + {"id": "user", "icon": "material/person", "label": "User"}, + {"id": "vpc", "groupType": "vpc", "label": "VPC", "children": [ + {"id": "alb", "icon": "aws/elastic-load-balancing", "label": "ALB"}, + {"id": "ecs", "icon": "aws/fargate", "label": "Fargate"}, + ]}, + {"id": "db", "icon": "aws/aurora", "label": "Aurora"}, + ], + "connections": [ + {"from": "user", "to": "alb"}, + {"from": "alb", "to": "ecs"}, + {"from": "ecs", "to": "db"}, + ], +} + + +# --------------------------------------------------------------------------- +# render_architecture +# --------------------------------------------------------------------------- + +def test_render_architecture_result_shape(): + result = render_architecture(copy.deepcopy(_SIMPLE_CHAIN), + x=100, y=180, width=1720, height=800) + assert isinstance(result["elements"], list) + assert result["elements"], "expected placed elements" + bbox = result["bbox"] + assert set(bbox) >= {"x", "y", "width", "height"} + metrics = result["metrics"] + for key in ("crossings", "pierces", "group_pierces", "overflow", "score"): + assert key in metrics + + +def test_render_architecture_simple_chain_is_clean(): + result = render_architecture(copy.deepcopy(_SIMPLE_CHAIN), + x=100, y=180, width=1720, height=800) + m = result["metrics"] + assert m["crossings"] == 0 + assert m["pierces"] == 0 + assert m["group_pierces"] == 0 + assert m["overflow"] == 0 + + +def test_render_architecture_element_types(): + result = render_architecture(copy.deepcopy(_SIMPLE_CHAIN), + x=100, y=180, width=1720, height=800) + types = {e["type"] for e in result["elements"]} + assert "arch-group" in types # the VPC frame + assert "image" in types # the icons + + +def test_render_architecture_include_metrics_false(): + result = render_architecture(copy.deepcopy(_SIMPLE_CHAIN), + x=100, y=180, width=1720, height=800, + include_metrics=False) + assert "metrics" not in result + assert result["elements"] + + +def test_render_architecture_does_not_mutate_input(): + tree = copy.deepcopy(_SIMPLE_CHAIN) + snapshot = copy.deepcopy(tree) + render_architecture(tree, x=100, y=180, width=1720, height=800) + assert tree == snapshot + + +def test_render_architecture_target_area_override(): + tree = copy.deepcopy(_SIMPLE_CHAIN) + tree["targetArea"] = {"x": 50, "y": 50, "width": 900, "height": 500} + result = render_architecture(tree) + bbox = result["bbox"] + # Layout must land inside (or at) the requested target area. + assert bbox["width"] <= 900 * 1.1 + assert bbox["height"] <= 500 * 1.1 + + +# --------------------------------------------------------------------------- +# build_layout +# --------------------------------------------------------------------------- + +def test_build_layout_scales_to_fit(): + nodes, groups, edges, rb, _h, _v = build_layout( + copy.deepcopy(_SIMPLE_CHAIN), 0, 0, 1720, 800) + assert nodes, "expected collected nodes" + assert "vpc" in groups + assert len(edges) == 3 + # Fit tolerance: the loop breaks within 3% of target. + assert rb[2] <= 1720 * 1.1 + + +# --------------------------------------------------------------------------- +# metrics +# --------------------------------------------------------------------------- + +def test_measure_detects_crossing(): + # Two edges forming an X: (0,0)->(100,100) and (0,100)->(100,0). + nodes = { + "a": {"x": -10, "y": -10, "width": 10, "height": 10}, + "b": {"x": 100, "y": 100, "width": 10, "height": 10}, + "c": {"x": -10, "y": 100, "width": 10, "height": 10}, + "d": {"x": 100, "y": -10, "width": 10, "height": 10}, + } + edges = [ + {"from": "a", "to": "b", "points": [(0, 0), (50, 0), (50, 100), (100, 100)]}, + {"from": "c", "to": "d", "points": [(0, 105), (50, 105), (50, 5), (100, 5)]}, + ] + m = measure_layout(nodes, {}, edges, [0, 0, 110, 115]) + assert m["crossings"] >= 1 + + +def test_measure_clean_layout_scores_zero_defects(): + m = measure(copy.deepcopy(_SIMPLE_CHAIN)) + assert m["crossings"] == 0 + assert m["pierces"] == 0 + assert m["group_pierces"] == 0 + + +def test_score_orders_defects(): + clean = measure(copy.deepcopy(_SIMPLE_CHAIN)) + dirty = dict(clean, crossings=clean["crossings"] + 2) + assert score(dirty) > score(clean) From 1c70242ec3b161833ece6f2fc3fa4dafe0dc19e9 Mon Sep 17 00:00:00 2001 From: ShotaroKataoka Date: Mon, 13 Jul 2026 16:18:13 +0900 Subject: [PATCH 78/79] refactor(layout): split 4.5k-line __init__.py into cohesive submodules Mechanical, behavior-preserving split of sdpm/layout/__init__.py (4,557 lines, 130+ functions) into six modules along responsibility boundaries: - model.py (284) node/group lookup, port geometry, elbow paths, box-node element generation - geometry.py (532) segment intersection, crossing pairs, node/group pierce detection, backwards segments, port sides - placement.py (610) scale, translate, align and collect geometry - ordering.py (812) crossing-minimizing child order, branch promotion, tile-pool reflow - routing.py (1141) orthogonal edge routing + port/group-bus/fan passes - refine.py (1268) bend optimization, side reselection, pierce detours, bend separation, fan-trunk rewriting __init__.py re-exports every name, so the existing import surfaces (pptx_builder's `from sdpm.layout import ...`, and `from . import ...` in render/metrics/graph) are unchanged. Import graph is a DAG: routing -> refine -> geometry -> model, with ordering and placement independent. Verified behavior-preserving: - render_architecture + measure output byte-identical before/after on 3 representative specs (chain, fan-merge, nested vertical groups) - 284 tests pass, ruff clean, all import surfaces verified --- skill/sdpm/layout/__init__.py | 4707 ++------------------------------ skill/sdpm/layout/geometry.py | 532 ++++ skill/sdpm/layout/model.py | 284 ++ skill/sdpm/layout/ordering.py | 812 ++++++ skill/sdpm/layout/placement.py | 610 +++++ skill/sdpm/layout/refine.py | 1268 +++++++++ skill/sdpm/layout/routing.py | 1141 ++++++++ 7 files changed, 4799 insertions(+), 4555 deletions(-) create mode 100644 skill/sdpm/layout/geometry.py create mode 100644 skill/sdpm/layout/model.py create mode 100644 skill/sdpm/layout/ordering.py create mode 100644 skill/sdpm/layout/placement.py create mode 100644 skill/sdpm/layout/refine.py create mode 100644 skill/sdpm/layout/routing.py diff --git a/skill/sdpm/layout/__init__.py b/skill/sdpm/layout/__init__.py index 6f6d3984..6583a5ef 100644 --- a/skill/sdpm/layout/__init__.py +++ b/skill/sdpm/layout/__init__.py @@ -1,4557 +1,154 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: MIT-0 -"""Layout engine: compute coordinates from logical structure JSON.""" - - -def optimize_order(tree, enable_reflow=True): - """Pre-process: reorder children in groups to minimize edge crossings. - - Handles both leaf-only AND mixed groups (groups containing sub-groups). - Uses brute-force permutation search for small groups (≤7 children) and - heuristic sorting for larger ones. Counts actual crossing pairs using a - two-layer position model (internal positions + external peer positions). - - ``enable_reflow`` runs the tile-pool reflow pass at the end. It is set - False when the reflow pass itself re-lays-out candidate arrangements, so - the real-routing evaluation does not recurse back into reflow. - - Mutates tree in-place. Call before _layout_scale. - """ - connections = tree.get("connections", []) - if not connections: - return - # Shape-first pre-pass: pull degree-1 auxiliary nodes that sit ON the main - # flow line out into a perpendicular lane so the straight through-edge does - # not pierce them (e.g. a Bedrock fallback wedged between Router and the - # model group). Runs before ordering so the new sub-groups get ordered too. - _promote_branch_nodes(tree, connections) - # Also tag hand-authored invisible lanes (e.g. a {router, bedrock} vertical - # column the LLM wrote directly) with their flow anchor, so the same - # anchor-on-flow alignment applies as for engine-promoted lanes. - _tag_manual_branch_anchors(tree, connections) - # Order children within each group to minimize crossings. Uses a routed- - # quality model that accounts not just for leaf-vs-leaf crossings but also - # for an edge to a sibling GROUP box having to detour around a nearer child - # (see _count_crossings_for_mixed_order's peer-adjacency term). - _optimize_group_order(tree, connections) - # Reflow tile pools: a group whose children are all anonymous, frameless, - # leaf-only sub-columns of one orientation (pure tiling with no semantic - # sub-grouping) can have its leaves reassigned across columns to shorten - # wiring — seating externally-connected leaves at the peer-facing end. - # Ordering alone can't do this (it never moves a leaf between sub-columns). - if enable_reflow: - _reflow_tile_pools(tree) - - -def _tag_manual_branch_anchors(node, connections, root=None): - """Tag hand-authored invisible linear sub-groups with their flow anchor. - - The engine's own _promote_branch_nodes tags the lanes IT creates, but an - author may hand-write the same shape — an invisible (no groupType/label) - horizontal/vertical group stacking a flow node with an auxiliary one, e.g. - ``{router, bedrock}`` so Bedrock sits beside Router. Block-placement then - centres that group by its bounding box, pushing the flow node off the main - line. We detect such a group and tag it with ``_branch_anchor`` = the sole - member that connects OUTSIDE the group (the flow node); the layout pass then - keeps that member on the flow line and lets the rest hang off to the side. - Mutates in place. Skips groups that already carry the tag. - """ - if root is None: - root = node - for child in node.get("children", []): - _tag_manual_branch_anchors(child, connections, root) - - children = node.get("children", []) - if len(children) < 2 or node.get("_branch_anchor"): - return - # Only invisible linear groups qualify (a visible box is a deliberate - # cluster, not a flow node + offset branch). - if node.get("direction") not in ("horizontal", "vertical"): - return - if node.get("groupType") or node.get("label"): - return - # Every direct child must be a bare leaf (the {anchor, branch} pattern). - member_ids = [] - for c in children: - if c.get("children") or "id" not in c: - return - member_ids.append(c["id"]) - member_set = set(member_ids) - - # An "anchor" is a member that connects to something OUTSIDE this group. - outward = [] - for c in children: - cid = c["id"] - for conn in connections: - other = None - if conn["from"] == cid: - other = conn["to"] - elif conn["to"] == cid: - other = conn["from"] - if other is not None and other not in member_set: - outward.append(cid) - break - # Tag only when exactly ONE member reaches outside — that is unambiguously - # the flow node; the rest are branches hanging off it. - if len(outward) == 1: - node["_branch_anchor"] = outward[0] - - -def _promote_branch_nodes(node, connections, root=None): - """Move degree-1 'branch' leaves off the main flow into a perpendicular lane. - - Detects the human-layout pattern: in a linear (horizontal/vertical) group, - a leaf ``b`` with exactly one connection whose anchor ``a`` is a sibling in - the same group, where another edge from ``a`` runs straight past ``b``'s - slot to a node on the far side. Drawn linearly, that through-edge pierces - ``b``. The fix (Shape-First / bend-minimisation philosophy: keep the main - line straight, displace the auxiliary node) wraps ``{a, b}`` into an - invisible sub-group oriented PERPENDICULAR to the parent, so ``a`` stays on - the flow line and ``b`` is offset to the side. Mutates ``node`` in place. - """ - if root is None: - root = node - children = node.get("children", []) - # Depth-first so inner groups are handled before we look at this level. - for child in children: - _promote_branch_nodes(child, connections, root) - children = node.get("children", []) - direction = node.get("direction") - if len(children) < 3 or direction not in ("horizontal", "vertical"): - return - - # Map each direct child -> the leaf ids it contains; and each leaf -> the - # index of the direct child that owns it (a sub-group counts as one slot). - leaf_to_idx = {} - for i, c in enumerate(children): - ids = [] - _collect_leaf_ids(c, ids) - for lid in ids: - leaf_to_idx[lid] = i - - # Global degree + neighbour list over all connections. - deg = {} - nbr = {} - for conn in connections: - for a, b in ((conn["from"], conn["to"]), (conn["to"], conn["from"])): - deg[a] = deg.get(a, 0) + 1 - nbr.setdefault(a, []).append(b) - - # Collect qualifying branch leaves, grouped by their anchor. - branches_by_anchor = {} - for i, c in enumerate(children): - if c.get("children"): - continue # only bare leaves can be branch nodes - bid = c.get("id") - if bid is None or deg.get(bid, 0) != 1: - continue - aid = nbr[bid][0] - ia = leaf_to_idx.get(aid) - if ia is None or children[ia].get("children"): - continue # anchor must be a direct-child leaf of this same group - # Is there a through-edge from the anchor that crosses b's slot? - ib = i - through = False - for other in nbr.get(aid, []): - if other == bid: - continue - ic = leaf_to_idx.get(other) - if ic is not None and (ia - ib) * (ic - ib) < 0: - through = True - break - if through: - branches_by_anchor.setdefault(aid, (ia, []))[1].append((ib, c)) - - if not branches_by_anchor: - return - - perp = "vertical" if direction == "horizontal" else "horizontal" - remove = set() - inserts = {} # insertion index -> new sub-group node - for aid, (ia, brs) in branches_by_anchor.items(): - anchor = children[ia] - slot = min([ia] + [ib for ib, _ in brs]) - # anchor first so it keeps the centred slot on the flow line; branches - # follow in their original order. - members = [anchor] + [c for _, c in sorted(brs)] - inserts[slot] = { - "id": "_branchlane_" + (aid or "x"), - "direction": perp, - "children": members, - # Remember which member is the flow anchor so the layout pass can - # keep IT (not the lane's centroid) on the main flow line, letting - # the branch hang off to the side. - "_branch_anchor": aid, - } - remove.add(ia) - remove.update(ib for ib, _ in brs) - - rebuilt = [] - for i, c in enumerate(children): - if i in inserts: - rebuilt.append(inserts[i]) - if i in remove: - continue - rebuilt.append(c) - node["children"] = rebuilt - - -def _optimize_group_order(node, connections, root=None): - """Recursively optimize child order within groups (leaf-only AND mixed).""" - if root is None: - root = node - children = node.get("children", []) - if not children: - return - - # Recurse depth-first so inner groups are optimized before outer ones - for child in children: - _optimize_group_order(child, connections, root) - - if len(children) < 2: - return - - # Collect ALL leaf ids reachable from each child (for mixed groups) - child_leaf_ids = {} - for c in children: - ids = [] - _collect_leaf_ids(c, ids) - child_leaf_ids[c["id"]] = ids - - # All leaf ids in this group (union of children's leaves) - all_leaf_ids = set() - for ids in child_leaf_ids.values(): - all_leaf_ids.update(ids) - - if not all_leaf_ids: - return - - # Collect connections relevant to any leaf in this group - relevant = [] - for conn in connections: - src, dst = conn["from"], conn["to"] - if src in all_leaf_ids or dst in all_leaf_ids: - relevant.append(conn) - - if not relevant: - return - - # Identify internal order constraints: - # If a connection goes from a leaf in child A to a leaf in child B, - # child A must come before child B. - leaf_to_child_id = {} - for cid, leaf_ids in child_leaf_ids.items(): - for lid in leaf_ids: - leaf_to_child_id[lid] = cid - - internal_order_constraints = [] - for conn in connections: - src, dst = conn["from"], conn["to"] - if src in leaf_to_child_id and dst in leaf_to_child_id: - src_child = leaf_to_child_id[src] - dst_child = leaf_to_child_id[dst] - if src_child != dst_child: - internal_order_constraints.append((src_child, dst_child)) - - # For brute-force: try all permutations if ≤7 children - if len(children) <= 7: - best_order = _find_min_crossing_order_mixed( - children, relevant, connections, internal_order_constraints, - child_leaf_ids, root - ) - if best_order is not None: - node["children"] = best_order - return - - # Fallback heuristic for larger groups: sort by connected peer position - flat_order = [] - _flatten_ids_from_root(root, flat_order) - id_position = {nid: i for i, nid in enumerate(flat_order)} - node["children"] = sorted(children, key=lambda c: _heuristic_sort_key_mixed(c, connections, id_position, child_leaf_ids)) - - -# Max leaves in a tile pool we will exhaustively reflow (n! candidate arrangements -# each re-routed; 6 → 720 is the practical ceiling for the local pass). -_REFLOW_MAX_LEAVES = 6 - - -def _is_tile_column(node): - """A tile is an anonymous, frameless, leaf-only sub-group (pure spacing, - no semantic meaning): no groupType, no label, no branch-anchor tag, and all - of its own children are leaves.""" - kids = node.get("children") - if not kids: - return False - if node.get("groupType") or node.get("label") or node.get("_branch_anchor"): - return False - return all(not k.get("children") for k in kids) - - -def _find_tile_pools(node, out): - """Collect groups that are pure tile pools. - - A tile pool is a group whose children are ALL anonymous frameless leaf-only - sub-columns (tiles) of the SAME orientation, with ≥2 tiles. Such a group is - a grid with no semantic sub-grouping, so its leaves are interchangeable - across tiles — safe to reassign to shorten wiring. - """ - kids = node.get("children", []) - if kids: - tiles = [k for k in kids if _is_tile_column(k)] - if len(tiles) >= 2 and len(tiles) == len(kids): - dirs = {t.get("direction") for t in tiles} - total = sum(len(t["children"]) for t in tiles) - if len(dirs) == 1 and 2 <= total <= _REFLOW_MAX_LEAVES: - out.append(node) - for k in kids: - _find_tile_pools(k, out) - - -def _defect_tuple(tree, width, height): - """Optimize child order (WITHOUT reflow), route the tree, and return the - lexicographic quality key used to compare tile arrangements: hard defects - first, then wire length as the soft tie-break. - - The intra-column order of each candidate is decided by the normal order - optimizer (reflow disabled so it can't recurse), so the reflow search only - has to explore how leaves are *partitioned* across columns — not their order - within a column.""" - import copy - from .render import build_layout - from .metrics import measure_layout - t = copy.deepcopy(tree) - optimize_order(t, enable_reflow=False) - nodes, groups, edges, rb, _ch, _cv = build_layout( - t, None, None, width, height, optimize=False) - m = measure_layout(nodes, groups, edges, rb, width, height) - return (round(m["overflow"], 3), m["crossings"], m["pierces"], - m["group_pierces"], m["backwards"], m["wire_norm"]) - - -def _column_partitions(leaf_ids, col_sizes): - """Yield every way to partition ``leaf_ids`` into ordered columns of the - given sizes, as a tuple of frozensets (membership only — intra-column order - is decided later by the order optimizer). Deduplicates equal-size columns so - (A|B) and (B|A) are not both tried.""" - from itertools import combinations - - def rec(remaining, sizes): - if not sizes: - yield () - return - size = sizes[0] - for combo in combinations(sorted(remaining), size): - rest = remaining - set(combo) - for tail in rec(rest, sizes[1:]): - yield (frozenset(combo),) + tail - - seen = set() - for parts in rec(set(leaf_ids), col_sizes): - # Canonicalize columns of equal size to dedupe symmetric partitions. - key = tuple(sorted(parts, key=lambda s: sorted(s))) - if key in seen: - continue - seen.add(key) - yield parts - - -def _reflow_tile_pools(tree, width=1720, height=800): - """Repartition leaves across the columns of each tile pool to shorten wiring. - - A tile pool is a group whose children are all anonymous, frameless, leaf-only - sub-columns of one orientation — pure tiling with no semantic sub-grouping. - Ordering alone never moves a leaf between sub-columns, so an author's column - split (e.g. Orders+Payments in one column, Catalog+Cart in the other) is - frozen even when regrouping would shorten wiring. - - For each pool we enumerate the ways to split its leaves across the columns - (membership only — each candidate's intra-column order is then set by the - normal order optimizer), re-route each with the REAL engine, and keep the - best. A candidate is kept only if it does not worsen any hard defect - (overflow/crossings/pierces/group_pierces/backwards) versus the author's - arrangement; wire length breaks ties. Because hard defects rank ahead of - wire, reflow can never trade a crossing for shorter wire — it is a pure - quality-preserving cleanup. - """ - pools = [] - _find_tile_pools(tree, pools) - if not pools: - return - - for pool in pools: - tiles = pool["children"] - col_sizes = [len(t["children"]) for t in tiles] - leaves = [leaf for t in tiles for leaf in t["children"]] - by_id = {leaf["id"]: leaf for leaf in leaves} - leaf_ids = [leaf["id"] for leaf in leaves] - - def apply_partition(parts): - """Fill each tile column with the members of the corresponding - partition set (intra-column order is refined later by the optimizer, - so any stable order is fine here).""" - for ci, members in enumerate(parts): - tiles[ci]["children"] = [by_id[i] for i in leaf_ids if i in members] - - # Baseline: the author's arrangement, order-optimized. - author_parts = tuple( - frozenset(leaf["id"] for leaf in tile["children"]) for tile in tiles) - best_parts = author_parts - best_key = _defect_tuple(tree, width, height) - - for parts in _column_partitions(leaf_ids, col_sizes): - if parts == author_parts: - continue - apply_partition(parts) - key = _defect_tuple(tree, width, height) - if key < best_key: - best_key = key - best_parts = parts - - apply_partition(best_parts) - # Let the order optimizer set the final intra-column order for the chosen - # partition (reflow disabled to avoid recursing into this pass). - optimize_order(tree, enable_reflow=False) - - -def _collect_leaf_ids(node, out): - """Collect all leaf node ids reachable from a node.""" - children = node.get("children", []) - if not children: - if "id" in node: - out.append(node["id"]) - return - for child in children: - _collect_leaf_ids(child, out) - - -def _find_min_crossing_order_mixed(children, relevant, all_connections, internal_order_constraints, child_leaf_ids, root): - """Try all permutations of children (mixed groups) and return the one with fewest crossings. - - For mixed groups, each child may be a leaf OR a sub-group containing multiple leaves. - The crossing count uses ALL leaves within each child's subtree. - """ - from itertools import permutations - - best_key = None - best_perm = None - - # Two position maps from the full tree: - # - leaf-only, for the crossing count (unchanged legacy behaviour — adding - # group ids here would reclassify group-endpoint edges and shuffle orders - # on unrelated diagrams like omnichannel). - # - group-inclusive, for the detour tie-break ONLY, so a many-to-one edge - # to a sibling GROUP box is visible when seating its single connected - # child (the DR diagram's API → Data tier). - leaf_positions = _compute_global_peer_positions(children, all_connections, child_leaf_ids, root) - group_positions = _compute_global_peer_positions(children, all_connections, child_leaf_ids, root, include_groups=True) - - for perm in permutations(children): - perm_ids = [c["id"] for c in perm] - - # Skip permutations that violate internal order constraints - if internal_order_constraints: - valid = True - for src, dst in internal_order_constraints: - if src in perm_ids and dst in perm_ids: - if perm_ids.index(src) > perm_ids.index(dst): - valid = False - break - if not valid: - continue - - crossings = _count_crossings_for_mixed_order(perm, relevant, leaf_positions, child_leaf_ids) - # Tie-break: among equal-crossing orders prefer the one where each child - # that connects to an OUTSIDE peer sits at the end of the row facing that - # peer. Otherwise a lone edge to a sibling group (e.g. an API container → - # the Data tier) leaves author order and detours around the outer - # sibling. This is a pure secondary key — it can never pick an order with - # more crossings — so it can't regress a diagram that ordering already - # solved; it only breaks ties the crossing count leaves open. - detour = _peer_detour_cost(perm, relevant, group_positions, child_leaf_ids) - key = (crossings, detour) - if best_key is None or key < best_key: - best_key = key - best_perm = list(perm) - if crossings == 0 and detour == 0: - break - - return best_perm - - -def _peer_detour_cost(perm, relevant, global_positions, child_leaf_ids): - """Secondary ordering key: how far each externally-connected child sits from - the row end facing its outside peer. - - For every edge between an internal leaf and an external peer (leaf OR group - box), the internal endpoint ideally sits at the row end nearest that peer — - the right end if the peer is to the right (higher global position than this - row's own centre), the left end if to the left. The cost sums the slot - distance from that ideal end; 0 when every connected child already hugs the - correct end. The datum is THIS row's mean peer-space position (not a global - average), so left/right is judged relative to the row itself — the fix for - the earlier version that flipped sides between otherwise-identical rows. - """ - n = len(perm) - # Slot of each internal leaf under this permutation, and per-child leaf sets. - leaf_slot = {} - internal = set() - child_leaf_sets = [] - for slot, child in enumerate(perm): - cl = set(child_leaf_ids.get(child["id"], [child["id"]])) - child_leaf_sets.append(cl) - for lid in cl: - leaf_slot[lid] = slot - internal.add(lid) - - # Only apply this tie-break when EXACTLY ONE direct child connects outside - # the group. That is the unambiguous "seat the one connected child at the - # peer-facing end" case (the DR diagram's API container). When several - # children connect outside, where each should sit is a multi-way trade the - # crossing model already handles; forcing one toward a peer end there just - # shuffles the row and can push another edge through a frame (the - # omnichannel services regression). Return 0 = no tie-break preference. - connected_children = 0 - for cl in child_leaf_sets: - if any((cn["from"] in cl and cn["to"] not in internal) - or (cn["to"] in cl and cn["from"] not in internal) - for cn in relevant): - connected_children += 1 - if connected_children != 1: - return 0 - - # This row's own centre in global peer-space: mean global position of the - # external peers it connects to (so "left/right" is relative to the row). - peer_positions = [] - for conn in relevant: - for a, b in ((conn["from"], conn["to"]), (conn["to"], conn["from"])): - if a in internal and b in global_positions and b not in internal: - peer_positions.append(global_positions[b]) - if not peer_positions: - return 0 - datum = sum(peer_positions) / len(peer_positions) - cost = 0 - for conn in relevant: - for a, b in ((conn["from"], conn["to"]), (conn["to"], conn["from"])): - if a in leaf_slot and b in global_positions and b not in internal: - ideal = (n - 1) if global_positions[b] >= datum else 0 - cost += abs(leaf_slot[a] - ideal) - return cost - - -def _compute_global_peer_positions(children, all_connections, child_leaf_ids, root, include_groups=False): - """Compute normalized positions for external peers. - - External peers are nodes NOT contained in any of the children being permuted. - Their position is based on DFS order of leaf nodes in the root tree, - normalized to a [0, N] range where N is the number of internal leaf slots. - - ``include_groups`` also registers GROUP ids at the mean position of their - member leaves. This is used ONLY by the detour tie-break (so a many-to-one - edge to a sibling group box is visible when seating the single connected - child). The crossing count deliberately uses the leaf-only map — adding - groups there reclassifies group-endpoint edges and shuffles unrelated - diagrams' orders. - """ - # All leaves within this group - all_internal = set() - for ids in child_leaf_ids.values(): - all_internal.update(ids) - - # Get global DFS order of ALL leaf nodes only - global_leaves = [] - _collect_leaf_ids(root, global_leaves) - - # Only external leaves get positions - external_leaves = [lid for lid in global_leaves if lid not in all_internal] - if not external_leaves: - return {} - - # Assign sequential positions to external leaves - pos = {lid: i for i, lid in enumerate(external_leaves)} - - if include_groups: - # Position external GROUP ids at the mean position of their members so a - # connection targeting a group BOX (e.g. an API container → the Data - # tier) is visible to the detour tie-break. - def _register(node): - ml = [] - _collect_leaf_ids(node, ml) - gid = node.get("id") - if gid is not None and node.get("children"): - ext = [pos[m] for m in ml if m in pos] - if ext and not any(m in all_internal for m in ml): - pos[gid] = sum(ext) / len(ext) - for ch in node.get("children", []): - _register(ch) - _register(root) - return pos - - -def _count_crossings_for_mixed_order(perm, relevant, global_positions, child_leaf_ids): - """Count edge crossings for a specific permutation of children in a mixed group. - - Two edges cross if their internal endpoints are in one order but their - external endpoints are in the opposite order. For edges where BOTH endpoints - are internal, they cross if one child's position inverts relative to another. - - Uses a two-layer approach: - - Internal positions: assigned based on permutation order (sequential ints) - - External positions: from global_positions (sequential ints, different namespace) - - For crossing detection, only edges sharing the same "side" (both internal-to-external - or both internal-to-internal) can cross each other. - """ - # Assign positions to all internal leaves based on the permutation order - internal_positions = {} - pos_counter = 0 - for child in perm: - cid = child["id"] - leaves = child_leaf_ids.get(cid, []) - if not leaves: - internal_positions[cid] = pos_counter - pos_counter += 1 - else: - for lid in leaves: - internal_positions[lid] = pos_counter - pos_counter += 1 - - # Categorize edges: - # Type A: internal→external (src is internal, dst is external) - # Type B: external→internal (src is external, dst is internal) - # Type C: internal→internal (both endpoints internal) - edges_a = [] # (internal_pos, external_pos) - edges_b = [] # (external_pos, internal_pos) - edges_c = [] # (internal_pos_src, internal_pos_dst) - - for conn in relevant: - src, dst = conn["from"], conn["to"] - src_int = internal_positions.get(src) - dst_int = internal_positions.get(dst) - src_ext = global_positions.get(src) - dst_ext = global_positions.get(dst) - - if src_int is not None and dst_int is not None: - edges_c.append((src_int, dst_int)) - elif src_int is not None and dst_ext is not None: - edges_a.append((src_int, dst_ext)) - elif src_ext is not None and dst_int is not None: - edges_b.append((src_ext, dst_int)) - - # Count crossings within each category - crossings = 0 - - # Type A crossings: two edges from internal to external cross if - # internal order and external order are inverted - for i in range(len(edges_a)): - for j in range(i + 1, len(edges_a)): - a1, b1 = edges_a[i] - a2, b2 = edges_a[j] - if (a1 - a2) * (b1 - b2) < 0: - crossings += 1 - - # Type B crossings: two edges from external to internal cross if - # external order and internal order are inverted - for i in range(len(edges_b)): - for j in range(i + 1, len(edges_b)): - a1, b1 = edges_b[i] - a2, b2 = edges_b[j] - if (a1 - a2) * (b1 - b2) < 0: - crossings += 1 - - # Type C crossings: internal-to-internal edges - for i in range(len(edges_c)): - for j in range(i + 1, len(edges_c)): - a1, b1 = edges_c[i] - a2, b2 = edges_c[j] - if (a1 - a2) * (b1 - b2) < 0: - crossings += 1 - - return crossings - - -def _heuristic_sort_key_mixed(child, connections, id_position, child_leaf_ids): - """Heuristic sort key for a child (leaf or sub-group) in a mixed group.""" - cid = child["id"] - leaf_ids = child_leaf_ids.get(cid, [cid]) - - weights = [] - for lid in leaf_ids: - for conn in connections: - if conn["from"] == lid and conn["to"] in id_position: - weights.append(id_position[conn["to"]]) - if conn["to"] == lid and conn["from"] in id_position: - weights.append(id_position[conn["from"]]) - if weights: - return sum(weights) / len(weights) - return id_position.get(cid, 0) - - -def _find_min_crossing_order(children, relevant, all_connections, internal_order_constraints=None): - """Try all permutations and return the one with fewest crossings.""" - from itertools import permutations - - best_crossings = None - best_perm = None - - # Determine fixed external peer positions from the tree structure - peer_positions = _compute_peer_positions(children, all_connections) - - for perm in permutations(children): - perm_ids = [c["id"] for c in perm] - - # Skip permutations that violate internal order constraints - if internal_order_constraints: - valid = True - for src, dst in internal_order_constraints: - if src in perm_ids and dst in perm_ids: - if perm_ids.index(src) > perm_ids.index(dst): - valid = False - break - if not valid: - continue - - crossings = _count_crossings_for_order(perm_ids, relevant, peer_positions) - if best_crossings is None or crossings < best_crossings: - best_crossings = crossings - best_perm = list(perm) - if crossings == 0: - break - - return best_perm - - -def _compute_peer_positions(children, all_connections): - """Compute fixed positions for external peers (nodes outside this group). - - External peers are assigned positions based on their relative order among - each other — determined by their index in the sibling group they belong to. - This position is independent of the permutation being tested. - """ - leaf_ids = set(c["id"] for c in children) - # Collect all external peers connected to this group - peers = set() - for conn in all_connections: - if conn["from"] in leaf_ids and conn["to"] not in leaf_ids: - peers.add(conn["to"]) - if conn["to"] in leaf_ids and conn["from"] not in leaf_ids: - peers.add(conn["from"]) - - # Group external peers by which group-internal nodes they connect to. - # Peers connecting to the same set of internal nodes should have the same position. - # Peers are ordered by their first appearance in connections list. - peer_order = [] - seen = set() - for conn in all_connections: - for p in [conn["from"], conn["to"]]: - if p in peers and p not in seen: - peer_order.append(p) - seen.add(p) - - # Assign a simple sequential position based on order of appearance - return {p: i for i, p in enumerate(peer_order)} - - -def _count_crossings_for_order(ordered_ids, relevant, peer_positions): - """Count edge crossings given a specific ordering of nodes in a group. - - Uses fixed peer_positions for external nodes and the permutation positions - for internal nodes. Two edges cross if their endpoint orders are inverted. - """ - pos = {nid: i for i, nid in enumerate(ordered_ids)} - id_set = set(ordered_ids) - - edges = [] - for conn in relevant: - src, dst = conn["from"], conn["to"] - if src in id_set and dst in id_set: - edges.append((pos[src], pos[dst])) - elif src in id_set: - ext_pos = peer_positions.get(dst, len(ordered_ids) / 2) - edges.append((pos[src], ext_pos)) - elif dst in id_set: - ext_pos = peer_positions.get(src, len(ordered_ids) / 2) - edges.append((ext_pos, pos[dst])) - - crossings = 0 - for i in range(len(edges)): - for j in range(i + 1, len(edges)): - a1, b1 = edges[i] - a2, b2 = edges[j] - if (a1 - a2) * (b1 - b2) < 0: - crossings += 1 - return crossings - - -def _flatten_ids_from_root(node, out): - """Collect all node ids in DFS order from a subtree root.""" - if "id" in node: - out.append(node["id"]) - for child in node.get("children", []): - _flatten_ids_from_root(child, out) - - -def _heuristic_sort_key(child_id, connections, id_position): - """Fallback heuristic: sort by average position of connected source nodes.""" - src_positions = [] - for conn in connections: - if conn["to"] == child_id and conn["from"] in id_position: - src_positions.append(id_position[conn["from"]]) - if src_positions: - return sum(src_positions) / len(src_positions) - weights = [] - for conn in connections: - if conn["from"] == child_id and conn["to"] in id_position: - weights.append(id_position[conn["to"]]) - if conn["to"] == child_id and conn["from"] in id_position: - weights.append(id_position[conn["from"]]) - if weights: - return sum(weights) / len(weights) - return id_position.get(child_id, 0) - - -def _layout_scale(node, parent_dir="horizontal", parent_align="center", spacing_scale_h=1.0, spacing_scale_v=1.0): - """Recursive layout engine. Calculates bindings (x, y, width, height) for each node bottom-up.""" - children = node.get("children", []) - direction = node.get("direction", parent_dir) - align = node.get("align", parent_align) - is_group = len(children) > 0 - icon_size = node.get("iconSize", 60) - - # A per-node spacing scale lets the fit pass compress ONE overflowing group - # (and its descendants) without touching sibling groups that already fit. - # Multiplies into the inherited factor so nested overrides compose. - spacing_scale_h *= node.get("_hscale", 1.0) - spacing_scale_v *= node.get("_vscale", 1.0) - - def sh(v): - return max(10, round(v * spacing_scale_h)) - def sv(v): - return max(10, round(v * spacing_scale_v)) - - if is_group: - has_visual_group = node.get("groupType") or node.get("label") - if has_visual_group: - margin = node.get("margin", {"top": sv(20), "right": sh(20), "bottom": sv(20), "left": sh(20)}) - padding = node.get("padding", {"top": sv(70), "right": sh(30), "bottom": sv(30), "left": sh(30)}) - else: - margin = node.get("margin", {"top": sv(5), "right": sh(5), "bottom": sv(5), "left": sh(5)}) - padding = node.get("padding", {"top": sv(5), "right": sh(5), "bottom": sv(5), "left": sh(5)}) - else: - box = node.get("box") - if box: - bw = box.get("width", 240) - if "height" in box: - bh = box["height"] - else: - char_per_line = max(1, bw // 10) - lines = 0 - for field in [box.get("sublabel"), box.get("title", node.get("id", "")), box.get("description")]: - if field: - for paragraph in str(field).split("\n"): - lines += max(1, -(-len(paragraph) // char_per_line)) - bh = lines * 24 + 40 - margin = node.get("margin", {"top": sv(20), "right": sh(20), "bottom": sv(20), "left": sh(20)}) - padding = {"top": 0, "right": 0, "bottom": 0, "left": 0} - node["_bindings"] = [0, 0, bw, bh] - node["_margin"] = margin - node["_padding"] = padding - return - label_h = sv(35) - raw_label = node.get("label", "") - label_lines = raw_label.replace("\\n", "\n").split("\n") - label_w = max((len(line) * 8 for line in label_lines), default=0) - label_h = sv(35) + (len(label_lines) - 1) * sv(18) - half_label_overhang = max(0, (label_w - icon_size) // 2) - margin = node.get("margin", {"top": sv(20), "right": max(sh(20), half_label_overhang + 5), "bottom": label_h + sv(10), "left": max(sh(20), half_label_overhang + 5)}) - padding = {"top": 0, "right": 0, "bottom": 0, "left": 0} - node["_bindings"] = [0, 0, icon_size, icon_size] - node["_margin"] = margin - node["_padding"] = padding - return - - for child in children: - _layout_scale(child, direction, align, spacing_scale_h, spacing_scale_v) - - reverse = node.get("reverse", False) - ordered = list(reversed(children)) if reverse else children - for i, child in enumerate(ordered): - cb = child["_bindings"] - cm = child["_margin"] - if i == 0: - dx = cm["left"] - cb[0] - dy = cm["top"] - cb[1] - _layout_translate(child, dx, dy) - else: - prev = ordered[i - 1] - pb = prev["_bindings"] - pm = prev["_margin"] - cb = child["_bindings"] - if direction == "horizontal": - nx = pb[0] + pb[2] + pm["right"] + cm["left"] - if align == "top": - ny = ordered[0]["_bindings"][1] - elif align == "bottom": - ny = pb[0 + 1] + pb[3] - cb[3] - else: - ny = pb[1] + (pb[3] - cb[3]) // 2 - _layout_translate(child, nx - cb[0], ny - cb[1]) - else: - ny = pb[1] + pb[3] + pm["bottom"] + cm["top"] - if align == "left": - nx = ordered[0]["_bindings"][0] - elif align == "right": - nx = pb[0] + pb[2] - cb[2] - else: - nx = pb[0] + (pb[2] - cb[2]) // 2 - _layout_translate(child, nx - cb[0], ny - cb[1]) - - # Post-process 1: align corresponding leaves across sibling vertical groups - # so that e.g. Lambda(row1) in group A has the same Y as DynamoDB(row1) in group B. - if direction == "horizontal": - _align_corresponding_leaves_y(ordered) - elif direction == "vertical": - _align_corresponding_leaves_x(ordered) - - # Post-process 2: align leaf nodes to the median leaf center of sibling groups. - # This ensures single icons sit at the visual center of adjacent vertical groups - # rather than at the center of the group's bounding box (which includes padding). - if align == "center" and direction == "horizontal": - _align_leaves_to_sibling_centers(ordered) - elif align == "center" and direction == "vertical": - _align_leaves_to_sibling_centers_h(ordered) - - # The alignment passes above translate LEAVES (including ones nested inside - # child sub-groups) without touching the sub-group's own box. Re-derive each - # child group's bbox bottom-up so its frame still wraps its (now-moved) - # icons — otherwise the box stays at the pre-alignment position and the - # shifted icons spill outside the frame (e.g. a Data Tier whose ElastiCache - # dropped below the solid border). - for c in children: - if c.get("children"): - _recompute_group_bbox(c) - - min_x = min(c["_bindings"][0] - c["_margin"]["left"] for c in children) - min_y = min(c["_bindings"][1] - c["_margin"]["top"] for c in children) - max_x = max(c["_bindings"][0] + c["_bindings"][2] + c["_margin"]["right"] for c in children) - max_y = max(c["_bindings"][1] + c["_bindings"][3] + c["_margin"]["bottom"] for c in children) - - gx = min_x - padding["left"] - gy = min_y - padding["top"] - gw = (max_x - min_x) + padding["left"] + padding["right"] - gh = (max_y - min_y) + padding["top"] + padding["bottom"] - - node["_bindings"] = [gx, gy, gw, gh] - node["_margin"] = margin - node["_padding"] = padding - - -def cancel_cross_axis_squash(tree, natural_sizes, cum_h, cum_v, target_w, target_h): - """Undo the global cross-axis squash on sibling groups that already fit. - - The global fit applies ONE scale per axis. On the CROSS axis (height for a - horizontal root, width for a vertical one) sibling groups don't sum — the - total is just the largest. So when one tall group (e.g. a 4-team agent tower) - forces the whole slide to squash vertically, short siblings (Entry, - Orchestration) get dragged down with it and turn unreadable. - - The cross axis can't be made to fit by scaling alone if the tall group is - genuinely too big (its icons, not just gaps, fill the height) — the layout - WILL overflow, and that's acceptable; the overflow warning tells the author - to restructure the tall group. What we refuse to accept is crushing the - groups that DID fit. So for each top-level group whose NATURAL cross-axis - size already fit the target, we cancel the global cross-axis squash by - giving it a compensating `_vscale`/`_hscale` ( = 1/cum ), restoring its - natural size; the oversized group keeps the squash. Mutates `tree`; returns - True if any compensation was assigned (caller re-runs `_layout_scale`). - """ - direction = tree.get("direction", "horizontal") - src_children = tree.get("children", tree.get("nodes", [])) - changed = False - # Only meaningful when the cross axis was actually compressed (<1). - if direction == "horizontal" and target_h and cum_v and cum_v < 0.97: - for src in src_children: - if not src.get("children"): - continue - if natural_sizes.get(id(src), (0, 0))[1] <= target_h: - src["_vscale"] = src.get("_vscale", 1.0) * (1.0 / cum_v) - changed = True - elif direction == "vertical" and target_w and cum_h and cum_h < 0.97: - for src in src_children: - if not src.get("children"): - continue - if natural_sizes.get(id(src), (0, 0))[0] <= target_w: - src["_hscale"] = src.get("_hscale", 1.0) * (1.0 / cum_h) - changed = True - return changed - - -def measure_natural_child_sizes(tree, root): - """Map each top-level source child -> its natural (w, h) before global fit. - - Keyed by id() of the source-child dict so it survives the deepcopy/rebuild - cycle as long as the caller passes the SAME tree dicts. Used by - cancel_cross_axis_squash to tell which groups fit on their own. - """ - out = {} - src_children = tree.get("children", tree.get("nodes", [])) - laid = root.get("children", []) - if len(laid) != len(src_children): - return out - for src, node in zip(src_children, laid): - b = node["_bindings"] - out[id(src)] = (b[2], b[3]) - return out - - -def _recompute_group_bbox(node): - """Re-derive a group's bbox from its children, bottom-up, in place. - - Used after the leaf-alignment passes move icons that live inside nested - sub-groups: those moves don't update the sub-group's own `_bindings`, so its - frame would otherwise stay where it was before the shift and no longer wrap - its icons. Recurses so deep nesting is corrected from the leaves up. Reuses - the group's stored `_padding` so the frame keeps its label band and margins. - - A grown sub-group can also start OVERLAPPING a sibling that was placed - against its old (smaller) bounds — e.g. a Data Tier that stretched to match - a tall sibling column now collides with the Observability group stacked - below it. After re-deriving child boxes we re-flow this node's children - along its own axis to restore the margin gaps, then derive this node's box. - """ - children = node.get("children") - if not children: - return - for c in children: - if c.get("children"): - _recompute_group_bbox(c) - _reflow_children_along_axis(node, children) - padding = node.get("_padding", {"top": 0, "right": 0, "bottom": 0, "left": 0}) - min_x = min(c["_bindings"][0] - c["_margin"]["left"] for c in children) - min_y = min(c["_bindings"][1] - c["_margin"]["top"] for c in children) - max_x = max(c["_bindings"][0] + c["_bindings"][2] + c["_margin"]["right"] for c in children) - max_y = max(c["_bindings"][1] + c["_bindings"][3] + c["_margin"]["bottom"] for c in children) - node["_bindings"] = [ - min_x - padding["left"], - min_y - padding["top"], - (max_x - min_x) + padding["left"] + padding["right"], - (max_y - min_y) + padding["top"] + padding["bottom"], - ] - - -def _reflow_children_along_axis(node, children): - """Push apart consecutive children that overlap on the group's main axis. - - Only moves along the layout axis (vertical group → shift Y, horizontal → - shift X) and only ever forward (never pulls a child back), so the cross-axis - alignment the leaf passes just established is preserved. A no-op when the - children already clear each other — the common case — so it cannot disturb - a layout that didn't grow. - """ - direction = node.get("direction", "horizontal") - if len(children) < 2: - return - reverse = node.get("reverse", False) - ordered = list(reversed(children)) if reverse else children - for i in range(1, len(ordered)): - prev = ordered[i - 1]["_bindings"] - pm = ordered[i - 1]["_margin"] - cur = ordered[i]["_bindings"] - cm = ordered[i]["_margin"] - if direction == "vertical": - need_top = prev[1] + prev[3] + pm["bottom"] + cm["top"] - delta = need_top - cur[1] - if delta > 0: - _layout_translate(ordered[i], 0, delta) - else: - need_left = prev[0] + prev[2] + pm["right"] + cm["left"] - delta = need_left - cur[0] - if delta > 0: - _layout_translate(ordered[i], delta, 0) - - -def _ranges_overlap(lo1, hi1, lo2, hi2): - """True if the 1-D intervals [lo1,hi1] and [lo2,hi2] overlap.""" - return lo1 < hi2 and lo2 < hi1 - - -def _cluster_groups_by_axis_overlap(groups_with_leaves, axis): - """Partition same-count groups into clusters that genuinely share a row - (axis="x", i.e. their X spans overlap → stacked vertically) or a column - (axis="y", i.e. their Y spans overlap → placed side by side). - - Leaf alignment only makes sense WITHIN such a cluster. Aligning the Nth - leaf across groups that are laid out along the same axis we're aligning - (e.g. forcing the 1st leaf of four side-by-side horizontal groups to one X) - collapses them onto each other — the bug this guards against. - """ - # bindings: [x, y, w, h]. axis "x" → position x(0), size w(2); - # axis "y" → position y(1), size h(3). - pos_idx = 0 if axis == "x" else 1 - size_idx = 2 if axis == "x" else 3 - items = [] - for group, leaves in groups_with_leaves: - b = group["_bindings"] - lo = b[pos_idx] - hi = b[pos_idx] + b[size_idx] - items.append((lo, hi, group, leaves)) - items.sort(key=lambda it: it[0]) - - clusters = [] - for lo, hi, group, leaves in items: - placed = False - for cluster in clusters: - # cluster shares the span if it overlaps ANY member - if any(_ranges_overlap(lo, hi, c_lo, c_hi) for c_lo, c_hi, _, _ in cluster): - cluster.append((lo, hi, group, leaves)) - placed = True - break - if not placed: - clusters.append([(lo, hi, group, leaves)]) - return [[(g, lv) for _, _, g, lv in cluster] for cluster in clusters] - - -def _align_corresponding_leaves_y(ordered): - """Align Y of corresponding leaves across vertical groups that sit SIDE BY - SIDE (their Y spans overlap). Groups stacked vertically must NOT be aligned - to each other — that would collapse them onto one row. - - Collects all vertical groups (at any depth) with the same leaf count, then - aligns Nth leaves to the same Y center only within each side-by-side cluster. - """ - vertical_groups = [] - for child in ordered: - _collect_vertical_groups(child, vertical_groups) - - if len(vertical_groups) < 2: - return - - by_count = {} - for group, leaves in vertical_groups: - n = len(leaves) - by_count.setdefault(n, []).append((group, leaves)) - - for groups_with_same_count in by_count.values(): - if len(groups_with_same_count) < 2: - continue - # Only align groups whose Y spans overlap (truly side by side). - for cluster in _cluster_groups_by_axis_overlap(groups_with_same_count, "y"): - if len(cluster) < 2: - continue - leaf_count = len(cluster[0][1]) - for row_idx in range(leaf_count): - row_leaves = [leaves[row_idx] for _, leaves in cluster] - centers = [leaf["_bindings"][1] + leaf["_bindings"][3] // 2 for leaf in row_leaves] - target_cy = max(centers) - for leaf in row_leaves: - b = leaf["_bindings"] - current_cy = b[1] + b[3] // 2 - dy = target_cy - current_cy - if dy != 0: - _layout_translate(leaf, 0, dy) - - -def _align_corresponding_leaves_x(ordered): - """Align X of corresponding leaves across horizontal groups that are STACKED - VERTICALLY (their X spans overlap). Groups placed side by side must NOT be - aligned to each other — that would collapse them onto one column. - """ - horizontal_groups = [] - for child in ordered: - _collect_horizontal_groups(child, horizontal_groups) - - if len(horizontal_groups) < 2: - return - - by_count = {} - for group, leaves in horizontal_groups: - n = len(leaves) - by_count.setdefault(n, []).append((group, leaves)) - - for groups_with_same_count in by_count.values(): - if len(groups_with_same_count) < 2: - continue - # Only align groups whose X spans overlap (truly stacked vertically). - for cluster in _cluster_groups_by_axis_overlap(groups_with_same_count, "x"): - if len(cluster) < 2: - continue - leaf_count = len(cluster[0][1]) - for col_idx in range(leaf_count): - col_leaves = [leaves[col_idx] for _, leaves in cluster] - centers = [leaf["_bindings"][0] + leaf["_bindings"][2] // 2 for leaf in col_leaves] - target_cx = max(centers) - for leaf in col_leaves: - b = leaf["_bindings"] - current_cx = b[0] + b[2] // 2 - dx = target_cx - current_cx - if dx != 0: - _layout_translate(leaf, dx, 0) - - -def _collect_vertical_groups(node, out): - """Collect the OUTERMOST vertical groups with their direct leaves. - - Descends through horizontal groups (their vertical children may be genuine - side-by-side peers) but STOPS at a vertical group: a vertical sub-group - nested inside another vertical column is that column's internal structure - (e.g. an {Inference, Bedrock} branch deep inside a Processing column), NOT a - peer of the column's top-level siblings. Collecting it would let row - alignment drag an unrelated top-level group down to match a deeply-nested - one — the bug that bent group-to-group arrows into L shapes. - """ - if not node.get("children"): - return - if node.get("direction", "horizontal") == "vertical": - leaves = [c for c in node["children"] if not c.get("children")] - # Only a CLEAN column (every direct child is a bare leaf) can be - # row-aligned: row N must mean the same thing in every column. A column - # that also holds a sub-group (e.g. Processing = three Lambdas + an - # {Inference, Bedrock} branch) has its leaves bunched at the top while a - # peer column spreads them over its full height — aligning row-by-row - # then drags the mixed column's box center off the flow line. Skip it. - if leaves and len(leaves) == len(node["children"]): - out.append((node, leaves)) - return # internals of a vertical column are not peers of its siblings - for child in node.get("children", []): - _collect_vertical_groups(child, out) - - -def _collect_horizontal_groups(node, out): - """Collect the OUTERMOST horizontal groups with their direct leaves. - - Mirror of _collect_vertical_groups: descends through vertical groups but - stops at a horizontal group, so a horizontal sub-row nested inside another - horizontal row is not column-aligned with that row's top-level siblings. - """ - if not node.get("children"): - return - if node.get("direction", "horizontal") == "horizontal": - leaves = [c for c in node["children"] if not c.get("children")] - # Only a CLEAN row (every direct child is a bare leaf) can be - # column-aligned — see _collect_vertical_groups for the rationale. - if leaves and len(leaves) == len(node["children"]): - out.append((node, leaves)) - return # internals of a horizontal row are not peers of its siblings - for child in node.get("children", []): - _collect_horizontal_groups(child, out) - - -def _get_direct_leaves(node): - """Get direct leaf children (non-recursively) of a node.""" - leaves = [] - for child in node.get("children", []): - if not child.get("children"): - leaves.append(child) - return leaves - - -def _layout_translate(node, dx, dy): - """Translate node and all descendants by (dx, dy).""" - b = node["_bindings"] - node["_bindings"] = [b[0] + dx, b[1] + dy, b[2], b[3]] - for child in node.get("children", []): - _layout_translate(child, dx, dy) - - -def _find_leaf_centers_y(node): - """Collect Y-centers of all leaf nodes in a subtree.""" - if not node.get("children"): - b = node["_bindings"] - return [b[1] + b[3] // 2] - centers = [] - for child in node["children"]: - centers.extend(_find_leaf_centers_y(child)) - return centers - - -def _find_leaf_centers_x(node): - """Collect X-centers of all leaf nodes in a subtree.""" - if not node.get("children"): - b = node["_bindings"] - return [b[0] + b[2] // 2] - centers = [] - for child in node["children"]: - centers.extend(_find_leaf_centers_x(child)) - return centers - - -def _align_leaves_to_sibling_centers(ordered): - """For horizontal layout: align leaf Y-center to sibling groups' direct-child leaf Y-center. - - Prioritizes leaves from groups with the same direction (horizontal), - since those represent the main flow continuation. - """ - # Collect cy of direct-child leaves from sibling groups with same direction - same_dir_leaf_centers = [] - for child in ordered: - if child.get("children") and child.get("direction", "horizontal") == "horizontal": - for grandchild in child["children"]: - if not grandchild.get("children"): - b = grandchild["_bindings"] - same_dir_leaf_centers.append(b[1] + b[3] // 2) - - # Fallback: for each leaf, find the adjacent group and use its leaf median - if not same_dir_leaf_centers: - leaf_indices = [i for i, c in enumerate(ordered) if not c.get("children")] - group_indices = [i for i, c in enumerate(ordered) if c.get("children")] - if leaf_indices and group_indices: - # Use the group nearest to the first leaf - first_leaf_idx = leaf_indices[0] - nearest_group_idx = min(group_indices, key=lambda g: abs(g - first_leaf_idx)) - centers = _find_leaf_centers_y(ordered[nearest_group_idx]) - if centers: - same_dir_leaf_centers.append((min(centers) + max(centers)) // 2) - - if not same_dir_leaf_centers: - return - - target_cy = (min(same_dir_leaf_centers) + max(same_dir_leaf_centers)) // 2 - - for child in ordered: - if not child.get("children"): - b = child["_bindings"] - current_cy = b[1] + b[3] // 2 - dy = target_cy - current_cy - if dy != 0: - _layout_translate(child, 0, dy) - _align_branch_lane_anchors(ordered, target_cy, axis="y") - - -def _align_leaves_to_sibling_centers_h(ordered): - """For vertical layout: align leaf X-center to sibling groups' direct-child leaf X-center.""" - same_dir_leaf_centers = [] - for child in ordered: - if child.get("children") and child.get("direction", "horizontal") == "vertical": - for grandchild in child["children"]: - if not grandchild.get("children"): - b = grandchild["_bindings"] - same_dir_leaf_centers.append(b[0] + b[2] // 2) - - if not same_dir_leaf_centers: - for child in ordered: - if child.get("children"): - for grandchild in child["children"]: - if not grandchild.get("children"): - b = grandchild["_bindings"] - same_dir_leaf_centers.append(b[0] + b[2] // 2) - - if not same_dir_leaf_centers: - for child in ordered: - if child.get("children"): - centers = _find_leaf_centers_x(child) - if centers: - same_dir_leaf_centers.extend(centers) - - if not same_dir_leaf_centers: - return - - target_cx = (min(same_dir_leaf_centers) + max(same_dir_leaf_centers)) // 2 - - for child in ordered: - if not child.get("children"): - b = child["_bindings"] - current_cx = b[0] + b[2] // 2 - dx = target_cx - current_cx - if dx != 0: - _layout_translate(child, dx, 0) - _align_branch_lane_anchors(ordered, target_cx, axis="x") - - -def _align_branch_lane_anchors(ordered, target, axis): - """Shift each promoted branch lane so its ANCHOR (not the lane centroid) - sits on the main flow line. - - A branch lane (created by _promote_branch_nodes) stacks {anchor, branch} - perpendicular to the flow. Block-placement centres the lane's bounding box, - which pushes the anchor off the flow axis. We translate the whole lane so - the anchor's center returns to ``target`` and the branch hangs off to the - side, keeping the through-flow edge straight. axis "y" → vertical flow - offset (horizontal parent); axis "x" → horizontal offset (vertical parent). - """ - pos_idx = 1 if axis == "y" else 0 - size_idx = 3 if axis == "y" else 2 - for child in ordered: - aid = child.get("_branch_anchor") - if not aid or not child.get("children"): - continue - anchor = next((m for m in child["children"] if m.get("id") == aid), None) - if anchor is None: - continue - ab = anchor["_bindings"] - anchor_center = ab[pos_idx] + ab[size_idx] // 2 - delta = target - anchor_center - if delta != 0: - if axis == "y": - _layout_translate(child, 0, delta) - else: - _layout_translate(child, delta, 0) - - -def _layout_collect(node, nodes_out, groups_out, prefix=""): - """Collect flat node/group dicts from tree.""" - nid = prefix + node["id"] if prefix else node["id"] - b = node["_bindings"] - entry = {"x": b[0], "y": b[1], "width": b[2], "height": b[3]} - if node.get("label"): - entry["label"] = node["label"] - children = node.get("children", []) - if children: - child_ids = [prefix + node["id"] + "." + c["id"] if prefix else node["id"] + "." + c["id"] for c in children] - entry["children"] = child_ids - entry["direction"] = node.get("direction", "horizontal") - pad = node.get("_padding", {}) - entry["_padding"] = pad - if node.get("groupType"): - entry["groupType"] = node["groupType"] - groups_out[nid] = entry - for child in children: - _layout_collect(child, nodes_out, groups_out, nid + ".") - else: - if node.get("icon"): - entry["icon"] = node["icon"] - if node.get("box"): - entry["box"] = node["box"] - nodes_out[nid] = entry - - -def _layout_route_connections(connections, nodes, groups=None): - """Route connections between nodes. Returns list of edge dicts with points.""" - groups = groups or {} - # Build node-to-group mapping and obstacle list - node_group = {} - for gid, g in groups.items(): - for cid in g.get("children", []): - node_group[cid] = gid - obstacles = [{"x": g["x"], "y": g["y"], "width": g["width"], "height": g["height"]} for g in groups.values()] - # Also add all nodes as obstacles so arrows avoid passing through icons - for nid, n in nodes.items(): - obstacles.append({"x": n["x"], "y": n["y"], "width": n.get("width", 60), "height": n.get("height", 60), "_node": nid}) - - port_counts = {} - port_indices = {} - - # First pass: identify reverse-flow connections (they use bottom ports, not side ports) - # Skip if explicit side hints are provided (graph layout mode). - # Only treat as reverse if the horizontal displacement is dominant (not a vertical connection). - reverse_set = set() - for i, conn in enumerate(connections): - if conn.get("srcSide") or conn.get("dstSide"): - continue - src = _find_node(nodes, conn["from"]) - dst = _find_node(nodes, conn["to"]) - if src and dst and dst["x"] + dst["width"] < src["x"]: - src_cy = src["y"] + src.get("height", 60) / 2 - dst_cy = dst["y"] + dst.get("height", 60) / 2 - dx = src["x"] - (dst["x"] + dst["width"]) - dy = abs(dst_cy - src_cy) - # Only treat as a reverse-flow (U-shaped detour) when the target is - # to the left AND roughly on the same row — a genuine feedback loop. - # If the target is also well above/below, a normal elbow routes it - # cleanly; the U-detour would wrap awkwardly into the wrong edge. - if dx > dy * 2: - reverse_set.add(i) - - # Track decided sides per source node to ensure consistency for fan-out - decided_src_side = {} - - # Identify fan-out sources (nodes with multiple forward targets). - # For fan-out nodes, pre-compute the best exit side by majority vote - # of what _auto_sides would choose, preferring horizontal ("right"/"left"). - _src_target_count: dict = {} - _src_side_votes: dict = {} # {src_id: {"right": n, "left": n, ...}} - for idx, conn in enumerate(connections): - if idx in reverse_set: - continue - if conn.get("srcSide") or conn.get("dstSide"): - continue - sid = conn["from"] - _src_target_count[sid] = _src_target_count.get(sid, 0) + 1 - src = _find_node(nodes, conn["from"]) - dst = _find_node(nodes, conn["to"]) - if src and dst: - s_side, _ = _auto_sides(src, dst, None) - _src_side_votes.setdefault(sid, {}) - _src_side_votes[sid][s_side] = _src_side_votes[sid].get(s_side, 0) + 1 - fanout_sources = {sid for sid, cnt in _src_target_count.items() if cnt >= 2} - - # Pre-decide a shared exit side for a fan-out source ONLY when a strict - # majority of its targets naturally want the same side. Forcing one side - # when targets are scattered (e.g. one to the right, one below-left) makes - # the minority arrows exit backwards through the source icon. When there is - # no majority, leave the source un-decided so each edge keeps its natural - # side. Among ties, prefer horizontal ("right" > "left") for left→right flow. - for sid in fanout_sources: - votes = _src_side_votes.get(sid, {}) - if not votes: - continue - total = sum(votes.values()) - # candidate = the side with the most votes (horizontal preferred on tie) - ordered = sorted(votes.items(), key=lambda kv: (-kv[1], {"right": 0, "left": 1, "bottom": 2, "top": 3}.get(kv[0], 9))) - best_side, best_n = ordered[0] - if best_n > total / 2: - decided_src_side[sid] = best_side - - conn_sides = [] - for i, conn in enumerate(connections): - src, _src_is_grp = _find_endpoint(nodes, groups, conn["from"]) - dst, _dst_is_grp = _find_endpoint(nodes, groups, conn["to"]) - if not src or not dst: - conn_sides.append((None, None, None, None)) - continue - if i in reverse_set: - conn_sides.append((src, dst, "bottom", "bottom")) - continue - - # Allow explicit side hints from connection spec - explicit_src = conn.get("srcSide") - explicit_dst = conn.get("dstSide") - - group_dir = None - src_gid = _find_group_for(conn["from"], node_group) - dst_gid = _find_group_for(conn["to"], node_group) - if src_gid and src_gid == dst_gid: - group_dir = groups[src_gid].get("direction", "horizontal") - src_side, dst_side = _auto_sides(src, dst, group_dir) - - if explicit_src: - src_side = explicit_src - if explicit_dst: - dst_side = explicit_dst - - # Consistency: if this source already has a decided side for forward connections, - # reuse it to prevent some arrows exiting from a different side (e.g. bottom). - # Skip this override when explicit sides are provided, or when the decided side - # is perpendicular to the natural direction (would create a bad route). - # Exception: for fan-out sources (1 source → N targets), ALWAYS apply the - # decided side to keep all arrows exiting from the same edge. - src_id = conn["from"] - if not explicit_src and not explicit_dst: - if src_id in decided_src_side: - decided = decided_src_side[src_id] - # For fan-out sources, always use the decided side. - # For non-fan-out, only apply if same axis (horizontal↔horizontal - # or vertical↔vertical) to avoid bad routes. - h_sides = {"left", "right"} - natural_axis = "h" if src_side in h_sides else "v" - decided_axis = "h" if decided in h_sides else "v" - # Never apply the decided side if the target lies on the OPPOSITE - # side, which would make the arrow exit backwards through the - # source icon (e.g. forcing "right" when the target is to the - # left). Check the target's actual direction relative to source. - decided_is_backwards = False - # Whether the decided side's axis matches the target's DOMINANT - # direction. Forcing a vertical (top/bottom) exit toward a target - # that is primarily to the side (or vice-versa) makes the arrow - # wrap awkwardly around the target — so only force when the axes - # agree, even for fan-out sources. - decided_axis_matches_target = True - if src and dst: - s_cx = src["x"] + src.get("width", 60) / 2 - s_cy = src["y"] + src.get("height", 60) / 2 - d_cx = dst["x"] + dst.get("width", 60) / 2 - d_cy = dst["y"] + dst.get("height", 60) / 2 - if decided == "right" and d_cx < s_cx: - decided_is_backwards = True - elif decided == "left" and d_cx > s_cx: - decided_is_backwards = True - elif decided == "bottom" and d_cy < s_cy: - decided_is_backwards = True - elif decided == "top" and d_cy > s_cy: - decided_is_backwards = True - adx = abs(d_cx - s_cx) - ady = abs(d_cy - s_cy) - target_axis = "h" if adx >= ady else "v" - decided_axis_matches_target = (target_axis == decided_axis) - apply_decided = ( - (not decided_is_backwards) - and decided_axis_matches_target - and ((src_id in fanout_sources) or (natural_axis == decided_axis)) - ) - if apply_decided: - src_side = decided - # Fix dst_side for fan-out: use opposite side, but if dst - # is directly above/below src (not to the side), keep natural dst_side - if src_id in fanout_sources: - if src and dst: - src_cx = src["x"] + src.get("width", 60) / 2 - dst_cx = dst["x"] + dst.get("width", 60) / 2 - src_cy = src["y"] + src.get("height", 60) / 2 - dst_cy = dst["y"] + dst.get("height", 60) / 2 - adx = abs(dst_cx - src_cx) - ady = abs(dst_cy - src_cy) - if ady > adx * 2: - # Target is mostly above/below — use natural sides - natural_src, natural_dst = _auto_sides(src, dst, None) - src_side = natural_src - dst_side = natural_dst - else: - # Target is to the side — use opposite - if src_side == "right": - dst_side = "left" - elif src_side == "left": - dst_side = "right" - elif src_side == "bottom": - dst_side = "top" - elif src_side == "top": - dst_side = "bottom" - else: - if src_side == "right": - dst_side = "left" - elif src_side == "left": - dst_side = "right" - elif src_side == "bottom": - dst_side = "top" - elif src_side == "top": - dst_side = "bottom" - else: - if src_side == "right" and dst_side == "top": - dst_side = "left" - elif src_side == "left" and dst_side == "bottom": - dst_side = "right" - elif src_side == "bottom" and dst_side == "right": - dst_side = "top" - elif src_side == "top" and dst_side == "left": - dst_side = "bottom" - else: - decided_src_side[src_id] = src_side - - conn_sides.append((src, dst, src_side, dst_side)) - sk = (conn["from"], src_side) - dk = (conn["to"], dst_side) - port_counts[sk] = port_counts.get(sk, 0) + 1 - port_counts[dk] = port_counts.get(dk, 0) + 1 - - # Optimize port assignment order to minimize crossings. - # Group connections by (node, side), then try permutations of port order. - # Exclude reverse connections (they use dedicated bottom ports). - port_groups = {} - for i, (src, dst, src_side, dst_side) in enumerate(conn_sides): - if src is None or i in reverse_set: - continue - sk = (connections[i]["from"], src_side) - dk = (connections[i]["to"], dst_side) - port_groups.setdefault(sk, []).append(i) - port_groups.setdefault(dk, []).append(i) - - port_indices = _optimize_port_order(port_groups, conn_sides, connections, nodes, port_counts, obstacles) - - # Compute global bounding box for detour routing - all_y = [] - for n in nodes.values(): - all_y.append(n["y"]) - all_y.append(n["y"] + n["height"]) - global_bottom = max(all_y) + 60 if all_y else 500 - - # Compute fan-out shared bend X for right-side fan-outs. - # All connections from a fan-out source share the same bend X (midpoint to nearest target). - fanout_bend_x = {} - for sid in fanout_sources: - if decided_src_side.get(sid) == "right": - src_node = _find_node(nodes, sid) - if not src_node: - continue - src_right = src_node["x"] + src_node["width"] - # Find nearest target left edge - target_lefts = [] - for idx, conn in enumerate(connections): - if conn["from"] == sid and idx not in reverse_set: - dst_node = _find_node(nodes, conn["to"]) - if dst_node: - target_lefts.append(dst_node["x"]) - # Only consider targets to the right of source - right_targets = [x for x in target_lefts if x > src_right] - if right_targets: - nearest_left = min(right_targets) - fanout_bend_x[sid] = src_right + (nearest_left - src_right) * 0.45 - elif target_lefts: - # All targets are to the left or same X — use a fixed offset - fanout_bend_x[sid] = src_right + 30 - - edges = [] - for i, conn in enumerate(connections): - src, dst, src_side, dst_side = conn_sides[i] - if src is None: - edges.append({"from": conn["from"], "to": conn["to"], "label": conn.get("label", ""), "points": []}) - continue - - # Group endpoints: the port sits on the group's box edge (no label - # offset), and the line is allowed to enter the box — so the group's - # own member icons are excluded from this edge's obstacles. - src_is_grp = _find_node(nodes, conn["from"]) is None - dst_is_grp = _find_node(nodes, conn["to"]) is None - - if i in reverse_set: - src_node = _find_node(nodes, conn["from"]) - dst_node = _find_node(nodes, conn["to"]) - label_h_src = 30 if src_node.get("label") else 0 - label_h_dst = 30 if dst_node.get("label") else 0 - sp = _port_point(src_node, "bottom", 0, 1, label_h_src) - tp = _port_point(dst_node, "bottom", 0, 1, label_h_dst) - points = _detour_path(sp, tp, "bottom", "bottom", global_bottom) - else: - # A group port uses no label height; a node port offsets the bottom - # edge by the label band. - src_label_h = 0 if src_is_grp else (30 if src.get("label") else 0) - dst_label_h = 0 if dst_is_grp else (30 if dst.get("label") else 0) - sp = _port_point(src, src_side, port_indices[(i, conn["from"])], port_counts[(conn["from"], src_side)], src_label_h) - tp = _port_point(dst, dst_side, port_indices[(i, conn["to"])], port_counts[(conn["to"], dst_side)], dst_label_h) - - # Route every edge with the standard elbow path. The downstream - # bend optimizer, side reselection, and detour passes shape each - # edge to minimize crossings/pierces — a shared fan-out trunk is - # no longer special-cased because, when targets sit on a row with - # an obstacle between them, a fixed trunk grazes that obstacle and - # no trunk position can avoid it (only a detour can). - excl = {conn["from"], conn["to"]} - # Connecting to/from a group means the line may pass into that - # group's box — exclude the group's member icons (and the group - # box itself) from this edge's obstacles. - if src_is_grp: - excl |= _group_member_ids(nodes, groups, conn["from"]) - if dst_is_grp: - excl |= _group_member_ids(nodes, groups, conn["to"]) - conn_obs = [o for o in obstacles if o.get("_node") not in excl] - points = _elbow_path(sp, tp, src_side, dst_side, conn_obs) - edge_entry = {"from": conn["from"], "to": conn["to"], "label": conn.get("label", ""), "points": points} - if src_is_grp: - edge_entry["_src_group"] = conn["from"] - if dst_is_grp: - edge_entry["_dst_group"] = conn["to"] - edges.append(edge_entry) - - # T8: Merge fan-out/fan-in groups onto a unified port + shared trunk when - # "fan": "merge" is set. This is treated as a HARD CONSTRAINT: the merged - # edges are tagged `_fan_locked` and every downstream pass leaves their - # trunks untouched. Crossing avoidance for merged fans comes from placement - # (the order/direction search) and from routing the OTHER edges around the - # fixed trunks — not from un-merging them. - _align_fan_bends(edges, conn_sides, connections, nodes, groups) - - # T9: Safe bend separation — shift overlapping vertical bends apart - # while preserving axis-alignment (only move X of vertical segments, - # never touch start/end points). Skips locked fan trunks. - _safe_separate_bends(edges) - - # T10: Bend optimization — slide each free (middle) bend of an elbow path - # along its axis to minimize a global cost (crossings + weighted icon - # pierces). The free bend of a 4-point VHV/HVH path can move without - # touching the port-anchored endpoints, so axis-alignment and - # perpendicularity are preserved. This is the primary crossing/pierce - # reducer; it never introduces diagonals or backwards segments. - _optimize_bends(edges, nodes) - - # T11: Side/port reselection — a remaining pierce is usually a poor choice - # of which icon edge the arrow attaches to. Re-route each still-piercing - # edge through the elbow router with an alternative (src_side, dst_side), - # keeping it only if it lowers pierces without raising crossings. Adds no - # segments (unlike a detour), so it cannot cause a crossing blow-up, and - # endpoints stay perpendicular because every port comes from _port_point. - obstacles_re = [o for o in obstacles if o.get("_node")] - # Guard the whole reselect pass on the global weighted defect score: the - # per-edge slack (allowing +1 crossing to clear a pierce) is locally sound - # but can accumulate across edges into a net-worse layout. Roll back if the - # weighted total (crossings + 1.5*pierces + 0.7*backwards) regresses. - _resel_before = _defect_weight((_count_all_crossings(edges), - _count_node_pierces(edges, nodes), - _count_backwards(edges, nodes))) - _resel_snapshot = [list(map(list, e["points"])) for e in edges] - _reselect_sides(edges, nodes, obstacles_re) - _resel_after = _defect_weight((_count_all_crossings(edges), - _count_node_pierces(edges, nodes), - _count_backwards(edges, nodes))) - if _resel_after > _resel_before: - for e, pts in zip(edges, _resel_snapshot): - e["points"] = pts - _optimize_bends(edges, nodes) - - # T12: Obstacle detour — for pierces that no side/port choice can clear - # (e.g. an icon stacked directly between source and target in the same - # column), splice an axis-aligned jog around the obstacle. Each candidate - # is judged by the weighted defect score (pierce 1.5 > cross 1.0), so a jog - # may add a crossing to lift a line off an icon it cuts through. The jog is - # spliced into the interior of a segment, so endpoints never move and no - # diagonal is ever produced. Guarded on the global weighted total so the - # per-edge slack can't accumulate into a net-worse layout. - _det_before = _defect_weight((_count_all_crossings(edges), - _count_node_pierces(edges, nodes) - + _count_group_pierces(edges, groups, nodes), - _count_backwards(edges, nodes))) - _det_snapshot = [list(map(list, e["points"])) for e in edges] - _detour_around_pierces(edges, nodes, groups) - _det_after = _defect_weight((_count_all_crossings(edges), - _count_node_pierces(edges, nodes) - + _count_group_pierces(edges, groups, nodes), - _count_backwards(edges, nodes))) - if _det_after > _det_before: - for e, pts in zip(edges, _det_snapshot): - e["points"] = pts - - # T13: Port recentering — by now each edge's actual entry/exit side may - # differ from the side that seeded port_counts (fan merge, side reselect, - # and elbow re-picks all move endpoints). That stale count left, e.g., a - # lone right-side edge sharing a "2 ports" slot and sitting off-center. - # Recompute the real per-(node, side) usage from geometry and redistribute - # the ports evenly along each edge, snapping the adjacent bend so the stub - # stays perpendicular. Fan-locked endpoints are fixed and excluded. - # Guarded per (node, side) group: each redistribution is kept only if it - # does not increase global crossings — centering a port can occasionally - # re-introduce a crossing that bend-opt had removed. - _recenter_ports(edges, nodes) - - # T14: Group bus bundling — when several edges share a GROUP endpoint - # (many-to-one to a box), bundle them so they enter/leave the box edge as a - # tidy parallel bus instead of fanning to scattered points. Nested lanes - # ordered by the opposite end keep the bundle crossing-free. Guarded on the - # global crossing count. - _align_group_bus(edges, nodes, groups) - - # T15: Straighten solo group-endpoint edges. A connection to a GROUP box - # defaults its port to the box center, so a node→tall-group (or small-group→ - # tall-group) edge bends into an L even when a straight run fits inside both - # facing edges — the "Step Functions → Processing" / "Processing → Shared" - # L-bends. Slide the port that has the larger box to the smaller endpoint's - # center so the arrow becomes a clean straight line. Guarded; bundles owned - # by the group bus (T14) are left alone. - _straighten_group_edges(edges, nodes, groups) - - # T16: U-turn a group-endpoint monitor edge around framed boxes. An edge - # from a GROUP box to a far node with framed groups in between (e.g. the ETL - # group → CloudWatch across the Consumers frame) defaults to a right-exit - # that the detour pass can only hack past, leaving a staircase that still - # grazes the frame. This one-shot pass tries a single clean U: exit the - # group's BOTTOM (or TOP), run a trunk just past the obstacle boxes, and - # enter the far node on the matching face. Committed only if it lowers the - # weighted defect total. Cheap: one candidate per qualifying edge. - _uturn_group_endpoint_edges(edges, nodes, groups) - - return edges - - -_UTURN_CLEAR = 20 - - -def _uturn_group_endpoint_edges(edges, nodes, groups): - """Reroute a solo group-endpoint edge that cuts framed boxes into a clean U. - - Targets an edge with a GROUP endpoint that still pierces one or more framed - group boxes (a monitor/aggregator line crossing the diagram). Builds ONE - candidate per vertical direction: leave the group box's bottom (or top) edge, - run a horizontal trunk just beyond ALL the boxes it would otherwise cross, - then rise/drop into the far endpoint on that same vertical face. Keeps the - candidate only if the global weighted defect total strictly improves and - crossings do not rise. O(edges × groups) — no port/side search. - """ - if not groups: - return edges - framed = [g for g in groups.values() if g.get("groupType")] - if not framed: - return edges - - def weighted(): - return _defect_weight((_count_all_crossings(edges), - _count_node_pierces(edges, nodes) - + _W_GROUP_PIERCE_ENGINE * _count_group_pierces(edges, groups, nodes), - _count_backwards(edges, nodes))) - - for e in edges: - if e.get("_fan_locked") or e.get("_fanout"): - continue - if len(e["points"]) < 2: - continue - # Must involve a group endpoint and still have a routing defect (a - # framed-box cut OR a non-endpoint icon pierce) the prior passes left — - # the staircase the detour produced still grazes icons/frames. - if not (e.get("_src_group") or e.get("_dst_group")): - continue - if (_count_group_pierces([e], groups, nodes) == 0 - and not _edge_pierces(e, nodes)): - continue - src, src_is_grp = _find_endpoint(nodes, groups, e["from"]) - dst, dst_is_grp = _find_endpoint(nodes, groups, e["to"]) - if not src or not dst: - continue - - # Boxes this edge must clear (framed, not its own endpoints/members). - efrom, eto = e["from"].rsplit(".", 1)[-1], e["to"].rsplit(".", 1)[-1] - boxes = [] - for gid, g in groups.items(): - if not g.get("groupType"): - continue - gshort = gid.rsplit(".", 1)[-1] - if gshort in (efrom, eto): - continue - members = _group_member_ids(nodes, groups, gid) - if efrom in members or eto in members: - continue - boxes.append(g) - if not boxes: - continue - - s_label = 0 if src_is_grp else (30 if src.get("label") else 0) - d_label = 0 if dst_is_grp else (30 if dst.get("label") else 0) - sx_c = src["x"] + src["width"] // 2 - dx_c = dst["x"] + dst["width"] // 2 - snap = [list(map(list, ee["points"])) for ee in edges] - before = weighted() - before_cross = _count_all_crossings(edges) - - best = None - for vside in ("bottom", "top"): - # Trunk Y just past every box on the chosen vertical side, and past - # both endpoints' own extents so the stubs don't clip their boxes. - if vside == "bottom": - trunk_y = max([g["y"] + g["height"] for g in boxes] - + [src["y"] + src["height"], dst["y"] + dst["height"]]) + _UTURN_CLEAR - sp = [sx_c, src["y"] + src["height"] + s_label] - tp = [dx_c, dst["y"] + dst["height"] + d_label] - else: - trunk_y = min([g["y"] for g in boxes] - + [src["y"], dst["y"]]) - _UTURN_CLEAR - sp = [sx_c, src["y"]] - tp = [dx_c, dst["y"]] - cand = [sp, [sp[0], trunk_y], [tp[0], trunk_y], tp] - e["points"] = cand - w = weighted() - if (w < before and _count_all_crossings(edges) <= before_cross - and (best is None or w < best[0])): - best = (w, [list(p) for p in cand]) - for ee, pts in zip(edges, snap): - ee["points"] = pts - - if best is not None: - e["points"] = best[1] - return edges - - -def _straighten_group_edges(edges, nodes, groups): - """Make a solo group-endpoint edge a straight line when one fits. - - A connection whose endpoint is a GROUP box gets its port at the box center, - so when the two endpoints differ in cross-axis extent (a 60px icon vs a - 765px column, or two columns of unequal height) the elbow router bends the - edge even though a single straight segment would fit inside both facing - edges. For each such edge whose two ports sit on facing horizontal (or - facing vertical) sides, we choose a common cross-axis coordinate that lies - inside BOTH endpoints' spans — preferring the SMALLER endpoint's center, so - single icons and small boxes attach at their visual middle and the larger - box absorbs the offset — and re-emit a straight 2-point edge. - - Left untouched: fan trunks (the merge is a hard constraint) and any edge - sharing a group endpoint with another edge (a many-to-one bundle owned by - the group-bus pass, T14). Guarded on the global weighted defect total so - straightening can never add a crossing, icon pierce, or frame pierce. - """ - if not edges: - return edges - - # Count edges per group endpoint so many-to-one bundles stay with the bus. - grp_use = {} - for e in edges: - if e.get("_src_group"): - grp_use[("src", e["_src_group"])] = grp_use.get(("src", e["_src_group"]), 0) + 1 - if e.get("_dst_group"): - grp_use[("dst", e["_dst_group"])] = grp_use.get(("dst", e["_dst_group"]), 0) + 1 - - def weighted(es): - return _defect_weight((_count_all_crossings(es), - _count_node_pierces(es, nodes) - + _count_group_pierces(es, groups, nodes), - _count_backwards(es, nodes))) - - for e in edges: - if e.get("_fan_locked") or e.get("_fanout"): - continue - pts = e["points"] - if len(pts) < 2: - continue - # Only group-endpoint edges suffer the box-center kink; node→node edges - # are already snapped straight by the elbow router when they line up. - if not (e.get("_src_group") or e.get("_dst_group")): - continue - if e.get("_src_group") and grp_use.get(("src", e["_src_group"]), 0) >= 2: - continue - if e.get("_dst_group") and grp_use.get(("dst", e["_dst_group"]), 0) >= 2: - continue - s_geom, _ = _find_endpoint(nodes, groups, e["from"]) - d_geom, _ = _find_endpoint(nodes, groups, e["to"]) - if not s_geom or not d_geom: - continue - first_h = abs(pts[0][1] - pts[1][1]) <= 2 - first_v = abs(pts[0][0] - pts[1][0]) <= 2 - last_h = abs(pts[-1][1] - pts[-2][1]) <= 2 - last_v = abs(pts[-1][0] - pts[-2][0]) <= 2 - - new_pts = None - if first_h and last_h and abs(pts[0][1] - pts[-1][1]) > 2: - # Both ports on left/right edges → straighten on a common Y. - lo = max(s_geom["y"], d_geom["y"]) - hi = min(s_geom["y"] + s_geom["height"], d_geom["y"] + d_geom["height"]) - if hi - lo > 2: - if s_geom["height"] <= d_geom["height"]: - c = s_geom["y"] + s_geom["height"] / 2 - else: - c = d_geom["y"] + d_geom["height"] / 2 - y = round(min(max(c, lo + 1), hi - 1)) - new_pts = [[pts[0][0], y], [pts[-1][0], y]] - elif first_v and last_v and abs(pts[0][0] - pts[-1][0]) > 2: - # Both ports on top/bottom edges → straighten on a common X. - lo = max(s_geom["x"], d_geom["x"]) - hi = min(s_geom["x"] + s_geom["width"], d_geom["x"] + d_geom["width"]) - if hi - lo > 2: - if s_geom["width"] <= d_geom["width"]: - c = s_geom["x"] + s_geom["width"] / 2 - else: - c = d_geom["x"] + d_geom["width"] / 2 - x = round(min(max(c, lo + 1), hi - 1)) - new_pts = [[x, pts[0][1]], [x, pts[-1][1]]] - if new_pts is None: - continue - - snap = [list(map(list, ee["points"])) for ee in edges] - before = weighted(edges) - e["points"] = new_pts - if weighted(edges) > before: - for ee, p in zip(edges, snap): - ee["points"] = p - return edges - - -_PORT_EPS = 4 -_GROUP_BUS_PORT_GAP = 26 -_GROUP_BUS_LANE_GAP = 14 - - -def _align_group_bus(edges, nodes, groups): - """Bundle edges that share a group endpoint into a parallel bus on the box. - - For each group that is the target (or source) of 2+ edges, route those - edges so their final (or first) approach runs as nested parallel lanes into - adjacent ports centered on the box edge facing the other ends. Ordering the - lanes by the opposite end's position keeps the bundle free of self-cross. - Kept only if it does not raise the global crossing count. - """ - if not groups: - return edges - - # Collect bundles: (group_id, role) -> list of edges, where role is 'dst' - # (edges ending at the group) or 'src' (edges starting at the group). - bundles = {} - for e in edges: - if len(e["points"]) < 2: - continue - # A fan-merged group edge is already a deliberate single-trunk bundle; - # leave it to the fan layout, don't re-bundle it here. - if e.get("_fan_locked"): - continue - if e.get("_dst_group"): - bundles.setdefault((e["_dst_group"], "dst"), []).append(e) - if e.get("_src_group"): - bundles.setdefault((e["_src_group"], "src"), []).append(e) - - for (gid, role), all_grp_edges in bundles.items(): - if len(all_grp_edges) < 2: - continue - g = _find_group(groups, gid) - if not g: - continue - gx, gy, gw, gh = g["x"], g["y"], g["width"], g["height"] - bcx, bcy = gx + gw / 2, gy + gh / 2 - - # The "free end" of each edge is the non-group end. - def free_pt(e): - return e["points"][0] if role == "dst" else e["points"][-1] - - # Assign each edge to the box side its OWN free end faces (not the - # bundle centroid). A group can radiate in several directions at once — - # e.g. Stream Processing → Firehose (right), → OpenSearch (above), - # → CloudWatch (below). Bundling all three onto one centroid side drags - # the up/down edges out the right face and makes them detour. Only edges - # that genuinely share a side should share a bus. - by_side = {} - for e in all_grp_edges: - fp = free_pt(e) - dx, dy = fp[0] - bcx, fp[1] - bcy - if abs(dx) >= abs(dy): - e_side = "left" if dx < 0 else "right" - else: - e_side = "top" if dy < 0 else "bottom" - by_side.setdefault(e_side, []).append(e) - - for side, grp_edges in by_side.items(): - # A single edge on a side keeps its natural port — nothing to bundle. - if len(grp_edges) < 2: - continue - vertical_ports = side in ("left", "right") # ports vary along Y - - snapshot = [list(map(list, e["points"])) for e in edges] - before = _count_all_crossings(edges) - - # Order edges by their free end's coordinate along the port axis so - # adjacent ports connect to adjacent sources (no self-cross). - grp_edges.sort(key=lambda e: free_pt(e)[1] if vertical_ports else free_pt(e)[0]) - n = len(grp_edges) - # Box-edge anchor coordinates (the fixed coordinate of the port line). - bx = gx if side == "left" else (gx + gw) # used when vertical_ports - by = gy if side == "top" else (gy + gh) # used otherwise - - for rank, e in enumerate(grp_edges): - off = (rank - (n - 1) / 2) * _GROUP_BUS_PORT_GAP - fp = free_pt(e) - # Nested lane: outer (farther from center) edges turn earlier so - # the bundle telescopes without crossing. - lane_depth = (n - rank) * _GROUP_BUS_LANE_GAP if role == "dst" else (rank + 1) * _GROUP_BUS_LANE_GAP - if vertical_ports: - py = round(bcy + off) - # Outer lanes turn farther from the box so it telescopes. - lane = (gx - 20 - lane_depth) if side == "left" else (gx + gw + 20 + lane_depth) - port = [bx, py] - if role == "dst": - e["points"] = [fp, [lane, fp[1]], [lane, py], port] - else: - e["points"] = [port, [lane, py], [lane, fp[1]], fp] - else: - px = round(bcx + off) - lane = (gy - 20 - lane_depth) if side == "top" else (gy + gh + 20 + lane_depth) - port = [px, by] - if role == "dst": - e["points"] = [fp, [fp[0], lane], [px, lane], port] - else: - e["points"] = [port, [px, lane], [fp[0], lane], fp] - - if _count_all_crossings(edges) > before: - for e, pts in zip(edges, snapshot): - e["points"] = pts - - return edges - - -def _recenter_ports(edges, nodes): - """Evenly redistribute each node-edge's ports using the ACTUAL drawn sides. - - Endpoints are the only points moved (plus the immediately adjacent bend, to - keep the first/last stub axis-aligned). A single edge on a side lands dead - center; N edges split the side into N+1 even slots, ordered by the position - of their opposite end so they don't cross at the port. This corrects the - off-center stubs left by stale port_counts after fan/side changes. - """ - def side_of(node, pt): - x, y = pt - nx, ny = node["x"], node["y"] - w = node.get("width", 60) - h = node.get("height", w) - if nx - _PORT_EPS <= x <= nx + w + _PORT_EPS: - if y >= ny + h - _PORT_EPS: - return "bottom" - if y <= ny + _PORT_EPS: - return "top" - if ny - _PORT_EPS <= y <= ny + h + _PORT_EPS: - if x <= nx + _PORT_EPS: - return "left" - if x >= nx + w - _PORT_EPS: - return "right" - return None - - # Gather endpoints to move: (node, side) -> list of (edge, end_index, opp_pt) - groups = {} - for e in edges: - pts = e["points"] - if len(pts) < 2 or e.get("_fanout"): - continue - for end_idx, nid in ((0, e["from"]), (-1, e["to"])): - # Skip the locked end of a fan edge (its port is the shared trunk port). - if e.get("_fan_locked"): - lock = e["_fan_locked"] - # fan_out: shared port at start; fan_in: shared port at end. - if (lock["mode"] == "fan_out" and end_idx == 0) or \ - (lock["mode"] == "fan_in" and end_idx == -1): - continue - node = _find_node(nodes, nid) - if node is None: - continue - s = side_of(node, pts[end_idx]) - if s is None: - continue - opp = pts[-1] if end_idx == 0 else pts[0] - groups.setdefault((nid, s), []).append((e, end_idx, opp, node)) - - for (nid, side), members in groups.items(): - node = members[0][3] - nx, ny = node["x"], node["y"] - w = node.get("width", 60) - h = node.get("height", w) - label_h = 30 if node.get("label") else 0 - n = len(members) - # Order members along the edge by the coordinate of their opposite end, - # so adjacent ports connect to adjacent targets (minimizes self-cross). - if side in ("left", "right"): - members.sort(key=lambda m: m[2][1]) # by opposite Y - else: - members.sort(key=lambda m: m[2][0]) # by opposite X - - # Snapshot the edges this group touches so we can roll back if centering - # the ports happens to add a crossing the optimizer had removed. - touched = {id(e): list(map(list, e["points"])) for e, _, _, _ in members} - before = _count_all_crossings(edges) - - for slot, (e, end_idx, opp, _node) in enumerate(members): - t = (slot + 1) / (n + 1) - pts = e["points"] - if side == "right": - newp = [nx + w, round(ny + h * t)] - elif side == "left": - newp = [nx, round(ny + h * t)] - elif side == "bottom": - newp = [round(nx + w * t), ny + h + label_h] - else: # top - newp = [round(nx + w * t), ny] - # Move the endpoint and snap the adjacent bend to keep the stub - # perpendicular: for a left/right port the stub is horizontal, so - # the neighbor shares the new Y; for top/bottom it shares the new X. - adj_idx = 1 if end_idx == 0 else len(pts) - 2 - if 0 <= adj_idx < len(pts): - if side in ("left", "right"): - pts[adj_idx] = [pts[adj_idx][0], newp[1]] - else: - pts[adj_idx] = [newp[0], pts[adj_idx][1]] - pts[end_idx] = newp - - if _count_all_crossings(edges) > before: - for e, _, _, _ in members: - e["points"] = touched[id(e)] - - -def _safe_separate_bends(edges): - """Separate overlapping vertical bends by shifting their X position. - - Rules: - - Only shifts X of vertical segments (never Y of horizontal segments) - - Never moves pts[0] or pts[-1] (port-anchored) - - When shifting a vertical segment's X, also updates the adjacent horizontal - segments' endpoints to maintain connectivity - - Minimum separation: 30px between parallel vertical bends - """ - MIN_SEP = 30 - - # Collect vertical segments: (edge_idx, seg_start_idx, x, y_lo, y_hi) - v_segs = [] - for ei, e in enumerate(edges): - pts = e["points"] - if e.get("_fanout") or e.get("_fan_locked"): - continue - for k in range(len(pts) - 1): - if abs(pts[k][0] - pts[k+1][0]) <= 3 and abs(pts[k][1] - pts[k+1][1]) > 10: - # Vertical segment, not touching start/end - if k == 0 or k == len(pts) - 2: - continue - x = pts[k][0] - y_lo = min(pts[k][1], pts[k+1][1]) - y_hi = max(pts[k][1], pts[k+1][1]) - v_segs.append((ei, k, x, y_lo, y_hi)) - - # Group vertical segments by similar X (within MIN_SEP) - v_segs.sort(key=lambda s: s[2]) - groups = [] - current_group = [] - for seg in v_segs: - if current_group and abs(seg[2] - current_group[0][2]) > MIN_SEP: - if len(current_group) >= 2: - groups.append(current_group) - current_group = [seg] - else: - current_group.append(seg) - if len(current_group) >= 2: - groups.append(current_group) - - # For each group, check if Y ranges overlap and spread X positions - for group in groups: - # Filter to segments with overlapping Y ranges - overlapping = [] - for i, seg in enumerate(group): - for other in group[i+1:]: - if seg[3] < other[4] and seg[4] > other[3]: - if seg not in overlapping: - overlapping.append(seg) - if other not in overlapping: - overlapping.append(other) - - if len(overlapping) < 2: - continue - - # Spread evenly around the center X - center_x = sum(s[2] for s in overlapping) / len(overlapping) - n = len(overlapping) - for i, (ei, k, old_x, y_lo, y_hi) in enumerate(sorted(overlapping, key=lambda s: s[3])): - new_x = round(center_x + (i - (n-1)/2) * MIN_SEP) - if new_x == old_x: - continue - pts = edges[ei]["points"] - # Shift the vertical segment - pts[k] = [new_x, pts[k][1]] - pts[k+1] = [new_x, pts[k+1][1]] - # Fix adjacent horizontal segments - if k > 0 and abs(pts[k-1][1] - pts[k][1]) <= 3: - pts[k-1] = [pts[k-1][0], pts[k-1][1]] # keep, connectivity maintained by polyline - if k+2 < len(pts) and abs(pts[k+1][1] - pts[k+2][1]) <= 3: - pass # horizontal after — connectivity OK since pts[k+1] x changed - - # Safety net: if any diagonal segments remain, insert L-shaped intermediates - for e in edges: - new_pts = [e["points"][0]] - pts = e["points"] - for k in range(1, len(pts)): - dx = abs(pts[k][0] - new_pts[-1][0]) - dy = abs(pts[k][1] - new_pts[-1][1]) - if dx > 3 and dy > 3: - new_pts.append([pts[k][0], new_pts[-1][1]]) - new_pts.append(pts[k]) - e["points"] = new_pts - - return edges - - -def _optimize_port_order(port_groups, conn_sides, connections, nodes, port_counts, obstacles): - """Find the port index assignment that minimizes edge crossings. - - For each (node, side) with multiple ports, try all permutations (≤6) - and pick the one with fewest crossings. For larger groups, use a - heuristic: sort ports by the Y (or X) coordinate of the peer endpoint. - """ - from itertools import permutations - - # Start with sequential assignment - port_indices = {} - port_cursors = {} - for i, (src, dst, src_side, dst_side) in enumerate(conn_sides): - if src is None: - continue - for nid, side in [(connections[i]["from"], src_side), (connections[i]["to"], dst_side)]: - k = (nid, side) - port_cursors[k] = port_cursors.get(k, 0) - port_indices[(i, nid)] = port_cursors[k] - port_cursors[k] += 1 - - # For each port group with ≥2 connections, optimize the order - for (nid, side), conn_indices in port_groups.items(): - if len(conn_indices) < 2: - continue - - count = port_counts[(nid, side)] - node = _find_node(nodes, nid) - if not node: - continue - - if len(conn_indices) <= 6: - # Brute force: try all permutations - best_crossings = None - best_assignment = None - - for perm in permutations(range(count)): - # Assign port indices according to this permutation - test_indices = dict(port_indices) - for slot, ci in enumerate(conn_indices): - test_indices[(ci, nid)] = perm[slot] - - crossings = _count_port_crossings(conn_indices, test_indices, conn_sides, connections, nodes, port_counts, obstacles) - if best_crossings is None or crossings < best_crossings: - best_crossings = crossings - best_assignment = perm - if crossings == 0: - break - - if best_assignment is not None: - for slot, ci in enumerate(conn_indices): - port_indices[(ci, nid)] = best_assignment[slot] - else: - # Heuristic: sort by peer endpoint coordinate - _heuristic_port_sort(conn_indices, nid, side, port_indices, conn_sides, connections, nodes) - - return port_indices - - -def _count_port_crossings(conn_indices, port_indices, conn_sides, connections, nodes, port_counts, obstacles): - """Count crossings among a set of edges sharing a port group.""" - # Generate paths for the relevant connections - paths = [] - for ci in conn_indices: - src, dst, src_side, dst_side = conn_sides[ci] - if src is None: - continue - label_h = 30 if src.get("label") else 0 - sp = _port_point(src, src_side, port_indices[(ci, connections[ci]["from"])], port_counts[(connections[ci]["from"], src_side)], label_h) - tp = _port_point(dst, dst_side, port_indices[(ci, connections[ci]["to"])], port_counts[(connections[ci]["to"], dst_side)], label_h) - path = _elbow_path(sp, tp, src_side, dst_side, obstacles) - paths.append(path) - - # Count pairwise segment crossings - crossings = 0 - for i in range(len(paths)): - for j in range(i + 1, len(paths)): - for si in range(len(paths[i]) - 1): - for sj in range(len(paths[j]) - 1): - if _segments_intersect(paths[i][si], paths[i][si + 1], paths[j][sj], paths[j][sj + 1]): - crossings += 1 - return crossings - - -def _perp_touch(v_x, h_y, h_x_min, h_x_max, v_y_min, v_y_max): - """True if a vertical seg (x=v_x, y in [v_y_min,v_y_max]) and a horizontal - seg (y=h_y, x in [h_x_min,h_x_max]) meet — counting T-junctions. - - The meeting point is (v_x, h_y). It must lie within BOTH segments' spans - (endpoints included), and be interior to AT LEAST ONE of them. The latter - excludes only a pure endpoint-to-endpoint touch (two stubs meeting at a - shared corner/port), which is not a visual crossing. A T-junction — where - one segment's endpoint lands in the middle of the other (e.g. an arrow - ending on a line another arrow runs along) — DOES count: the previous - strict-interior test silently dropped these, so two arrows sharing a y and - overlapping in x read as uncrossed when they visibly overlap. - """ - if not (h_x_min <= v_x <= h_x_max and v_y_min <= h_y <= v_y_max): - return False - v_interior = v_y_min < h_y < v_y_max - h_interior = h_x_min < v_x < h_x_max - return v_interior or h_interior - - -def _segments_intersect(a1, a2, b1, b2): - """Test if two axis-aligned segments cross or overlap (for port optimization).""" - ax1, ay1 = a1 - ax2, ay2 = a2 - bx1, by1 = b1 - bx2, by2 = b2 - - a_horiz = ay1 == ay2 - a_vert = ax1 == ax2 - b_horiz = by1 == by2 - b_vert = bx1 == bx2 - - if a_horiz and b_vert: - h_y = ay1 - h_x_min, h_x_max = min(ax1, ax2), max(ax1, ax2) - v_x = bx1 - v_y_min, v_y_max = min(by1, by2), max(by1, by2) - return _perp_touch(v_x, h_y, h_x_min, h_x_max, v_y_min, v_y_max) - if a_vert and b_horiz: - v_x = ax1 - v_y_min, v_y_max = min(ay1, ay2), max(ay1, ay2) - h_y = by1 - h_x_min, h_x_max = min(bx1, bx2), max(bx1, bx2) - return _perp_touch(v_x, h_y, h_x_min, h_x_max, v_y_min, v_y_max) - if a_horiz and b_horiz and ay1 == by1: - a_min, a_max = min(ax1, ax2), max(ax1, ax2) - b_min, b_max = min(bx1, bx2), max(bx1, bx2) - return min(a_max, b_max) - max(a_min, b_min) > 5 - if a_vert and b_vert and ax1 == bx1: - a_min, a_max = min(ay1, ay2), max(ay1, ay2) - b_min, b_max = min(by1, by2), max(by1, by2) - return min(a_max, b_max) - max(a_min, b_min) > 5 - return False - - -def _heuristic_port_sort(conn_indices, nid, side, port_indices, conn_sides, connections, nodes): - """Sort ports by peer endpoint coordinate when brute force is too expensive.""" - peer_coords = [] - for ci in conn_indices: - src, dst, src_side, dst_side = conn_sides[ci] - if connections[ci]["from"] == nid: - peer = _find_node(nodes, connections[ci]["to"]) - coord = (peer["y"] + peer["height"] // 2) if peer else 0 - else: - peer = _find_node(nodes, connections[ci]["from"]) - coord = (peer["y"] + peer["height"] // 2) if peer else 0 - peer_coords.append((coord, ci)) - - # Sort by peer Y coordinate (or X for vertical sides) - if side in ("top", "bottom"): - for ci in conn_indices: - src, dst, src_side, dst_side = conn_sides[ci] - if connections[ci]["from"] == nid: - peer = _find_node(nodes, connections[ci]["to"]) - coord = (peer["x"] + peer["width"] // 2) if peer else 0 - else: - peer = _find_node(nodes, connections[ci]["from"]) - coord = (peer["x"] + peer["width"] // 2) if peer else 0 - peer_coords.append((coord, ci)) - peer_coords = peer_coords[len(conn_indices):] - - peer_coords.sort(key=lambda t: t[0]) - for slot, (_, ci) in enumerate(peer_coords): - port_indices[(ci, nid)] = slot - - -# Max spread between dst (or src) centers to allow grouping -_FAN_SPREAD_LIMIT = 600 - - -def _align_fan_bends(edges, conn_sides, connections, nodes=None, groups=None): - """Align bend positions and merge ports for fan-out and fan-in groups. - - Only activates when connections have "fan": "merge" set. A merged group is - a hard constraint: all edges sharing the same source (fan-out) or the same - target (fan-in) are forced onto ONE unified port and a shared trunk bend, - regardless of which icon edge the router originally chose. The side is - decided by majority vote across the group's edges so a single odd-side edge - no longer splinters the group (the previous (from, src_side) keying did). - - After this runs, each rewritten edge carries `_fan_locked` so downstream - optimizers (bend slide, side reselect, detour) leave its trunk alone — the - merge is the spec, and crossing reduction must work AROUND it, not undo it. - """ - def _apply_fan_guarded(indices, mode): - # The user wants same-purpose edges merged, so a merge that adds only a - # MODEST number of crossings is kept (a tidy trunk reads better than a - # few crossings). Roll back when: - # - the merged trunk PIERCES any icon. A fan bundle is `_fan_locked`, - # so the downstream pierce-resolution passes (side reselect, detour) - # CANNOT clear it later — whatever the locked trunk cuts through is - # permanent. Individually-routed edges, by contrast, get cleaned up - # by those passes, so an unmerged fan whose members pierce here may - # still reach 0 pierces in the final layout. Hence the test is - # "trunk pierces anything at all" (after_p > 0), not the weaker - # "merge ADDED pierces" — the latter compares two pre-optimization - # snapshots and wrongly keeps a doomed locked trunk (e.g. four - # agents fanning into a Bedrock hub through the icons below them). - # - the merge adds MORE crossings than the bundle size — a sign the - # trunk is fighting another structure (e.g. a hub that is both a - # fan-in and fan-out target), where separate routing is cleaner. - snap = {j: list(map(list, edges[j]["points"])) for j in indices} - before_c = _count_all_crossings(edges) - _rewrite_fan(edges, conn_sides, indices, mode=mode, nodes=nodes, groups=groups) - after_p = _count_node_pierces([edges[j] for j in indices], nodes) - after_c = _count_all_crossings(edges) - if after_p > 0 or (after_c - before_c) > len(indices): - for j in indices: - edges[j]["points"] = snap[j] - edges[j].pop("_fan_locked", None) - - # Fan-out: group purely by source node (side decided later by vote). - src_groups = {} - for i, (src, dst, src_side, dst_side) in enumerate(conn_sides): - if src is None or len(edges[i]["points"]) < 2: - continue - if connections[i].get("fan") != "merge": - continue - src_groups.setdefault(connections[i]["from"], []).append(i) - - for indices in src_groups.values(): - if len(indices) < 2: - continue - _apply_fan_guarded(indices, "fan_out") - - # Fan-in: group purely by target node. - dst_groups = {} - for i, (src, dst, src_side, dst_side) in enumerate(conn_sides): - if src is None or len(edges[i]["points"]) < 2: - continue - if connections[i].get("fan") != "merge": - continue - dst_groups.setdefault(connections[i]["to"], []).append(i) - - for indices in dst_groups.values(): - if len(indices) < 2: - continue - _apply_fan_guarded(indices, "fan_in") - - -_MAX_RESOLVE_ITERATIONS = 30 -_BEND_CANDIDATES = [-120, -100, -80, -60, -50, -40, -30, -20, -10, 10, 20, 30, 40, 50, 60, 80, 100, 120] - - -def _spread_overlapping_bends(edges, conn_sides, connections): - """Iteratively resolve edge crossings and separate close bends. - - Phase 1: Resolve all crossings by searching for optimal bend shifts. - Phase 2: Separate bends that are too close (even if not crossing). - """ - # Phase 1: resolve crossings - for _iteration in range(_MAX_RESOLVE_ITERATIONS): - crossing = _find_first_crossing(edges) - if crossing is None: - break - i, si, j, sj = crossing - _resolve_crossing_search(edges, i, si, j, sj) - - # Phase 2: separate close parallel bends - _separate_close_bends(edges) - - -def _find_first_crossing(edges): - """Find the first pair of crossing segments across all edges.""" - for i in range(len(edges)): - pts_i = edges[i]["points"] - if len(pts_i) < 2 or edges[i].get("_fanout"): - continue - for j in range(i + 1, len(edges)): - pts_j = edges[j]["points"] - if len(pts_j) < 2 or edges[j].get("_fanout"): - continue - for si in range(len(pts_i) - 1): - for sj in range(len(pts_j) - 1): - if _segments_intersect(pts_i[si], pts_i[si + 1], pts_j[sj], pts_j[sj + 1]): - return (i, si, j, sj) - return None - - -def _segments_overlap_collinear(a1, a2, b1, b2): - """True if the two axis-aligned segments lie on the same line and overlap - (as opposed to crossing perpendicularly).""" - if a1[1] == a2[1] and b1[1] == b2[1] and a1[1] == b1[1]: # both horizontal, same Y - a_min, a_max = min(a1[0], a2[0]), max(a1[0], a2[0]) - b_min, b_max = min(b1[0], b2[0]), max(b1[0], b2[0]) - return min(a_max, b_max) - max(a_min, b_min) > 5 - if a1[0] == a2[0] and b1[0] == b2[0] and a1[0] == b1[0]: # both vertical, same X - a_min, a_max = min(a1[1], a2[1]), max(a1[1], a2[1]) - b_min, b_max = min(b1[1], b2[1]), max(b1[1], b2[1]) - return min(a_max, b_max) - max(a_min, b_min) > 5 - return False - - -def _segments_cross(a1, a2, b1, b2): - """Test if two axis-aligned line segments (a1-a2) and (b1-b2) cross or overlap. - - Detects: - 1. Perpendicular crossings (one horizontal, one vertical) - 2. Collinear overlap (parallel segments sharing the same axis with overlapping range) - - Used by the builder's conservative edge-crossing warning. Distinct from - ``_segments_intersect`` (which counts T-junctions via ``_perp_touch``): this - one uses strict interior ``<`` on both segments so a shared endpoint does not - read as a crossing. - """ - ax1, ay1 = a1 - ax2, ay2 = a2 - bx1, by1 = b1 - bx2, by2 = b2 - - a_horiz = ay1 == ay2 - a_vert = ax1 == ax2 - b_horiz = by1 == by2 - b_vert = bx1 == bx2 - - # Perpendicular crossings - if a_horiz and b_vert: - h_y = ay1 - h_x_min, h_x_max = min(ax1, ax2), max(ax1, ax2) - v_x = bx1 - v_y_min, v_y_max = min(by1, by2), max(by1, by2) - return h_x_min < v_x < h_x_max and v_y_min < h_y < v_y_max - if a_vert and b_horiz: - v_x = ax1 - v_y_min, v_y_max = min(ay1, ay2), max(ay1, ay2) - h_y = by1 - h_x_min, h_x_max = min(bx1, bx2), max(bx1, bx2) - return h_x_min < v_x < h_x_max and v_y_min < h_y < v_y_max - - # Collinear overlap: both horizontal on same Y - if a_horiz and b_horiz and ay1 == by1: - a_min, a_max = min(ax1, ax2), max(ax1, ax2) - b_min, b_max = min(bx1, bx2), max(bx1, bx2) - overlap = min(a_max, b_max) - max(a_min, b_min) - return overlap > 5 - - # Collinear overlap: both vertical on same X - if a_vert and b_vert and ax1 == bx1: - a_min, a_max = min(ay1, ay2), max(ay1, ay2) - b_min, b_max = min(by1, by2), max(by1, by2) - overlap = min(a_max, b_max) - max(a_min, b_min) - return overlap > 5 - - return False - - -def _find_crossing_pairs(edges): - """Return the set of edge-index pairs (i, j) that genuinely cross. - - This is the single source of truth for "do two edges cross"; both the QA - metric (:func:`_count_all_crossings`, which just takes ``len``) and the - builder's human-readable warning consume it, so the two can never disagree. - - Two edges that SHARE an endpoint node (a fan-out from the same source or a - fan-in to the same target) are allowed to run on top of each other on their - shared trunk — that overlap IS the merged bundle, not a crossing. So for - such pairs we ignore collinear overlaps and only count a genuine - perpendicular crossing. Unrelated edges still count overlaps (two separate - arrows drawn on the same line read as a defect). - - Shared-endpoint pairs also produce a perpendicular T-junction where each - spoke peels off the shared trunk at its own port — the meeting point sits at - the spoke's true endpoint (pts[0] / pts[-1]). That T is the bundle's - intended structure, not a crossing, so it is skipped. A meeting that is - interior to BOTH polylines (a genuine 4-way X, e.g. two spokes crossing - mid-span) is always counted, even for a shared-endpoint pair.""" - # Pre-compute each edge's bounding box once; two edges whose boxes don't - # overlap can't cross, so we skip the O(segments²) inner test entirely. - # This is the hot path (called thousands of times by the bend/side/detour - # optimizers), so the cheap box reject saves the bulk of the work. - boxes = [] - for e in edges: - pts = e["points"] - if len(pts) < 2: - boxes.append(None) - continue - xs = [p[0] for p in pts] - ys = [p[1] for p in pts] - boxes.append((min(xs), min(ys), max(xs), max(ys))) - - pairs = set() - for i in range(len(edges)): - pts_i = edges[i]["points"] - if len(pts_i) < 2: - continue - ei = edges[i] - bi = boxes[i] - for j in range(i + 1, len(edges)): - pts_j = edges[j]["points"] - if len(pts_j) < 2: - continue - bj = boxes[j] - # Bounding-box reject: no overlap → no crossing. - if bi[0] > bj[2] or bj[0] > bi[2] or bi[1] > bj[3] or bj[1] > bi[3]: - continue - ej = edges[j] - shares_endpoint = ( - ei.get("from") == ej.get("from") - or ei.get("to") == ej.get("to") - or ei.get("from") == ej.get("to") - or ei.get("to") == ej.get("from") - ) - found = False - for si in range(len(pts_i) - 1): - if found: - break - for sj in range(len(pts_j) - 1): - if _segments_intersect(pts_i[si], pts_i[si + 1], pts_j[sj], pts_j[sj + 1]): - if shares_endpoint: - # A shared-endpoint bundle's collinear overlap is the - # intended trunk, not a crossing. - if _segments_overlap_collinear( - pts_i[si], pts_i[si + 1], pts_j[sj], pts_j[sj + 1] - ): - continue - # Two edges of the SAME fan bundle (same shared trunk) - # meet where each spoke peels off that trunk — a - # structural T-junction, not a crossing. Skip it, but - # only for the trunk-peel T: a genuine interior× - # interior X (two spokes truly crossing mid-span) is - # still counted. - if _is_fan_trunk_t_junction( - ei, ej, pts_i, si, pts_j, sj - ): - continue - pairs.add((i, j)) - found = True - break - return pairs - - -def _count_all_crossings(edges): - """Count crossing SEGMENT-pairs across all edges. - - NOTE: this counts every crossing segment-pair, so two edges that cross at - several segments contribute more than one. That is deliberate — the whole - order/reflow/bend search was tuned against this magnitude, so it must stay a - segment count, NOT a distinct-edge-pair count. For the human-readable "which - edges cross" warning use :func:`_find_crossing_pairs` (distinct edge pairs); - both share the same per-segment skip rules so they never disagree on - *whether* a pair crosses, only on how the total is tallied.""" - boxes = [] - for e in edges: - pts = e["points"] - if len(pts) < 2: - boxes.append(None) - continue - xs = [p[0] for p in pts] - ys = [p[1] for p in pts] - boxes.append((min(xs), min(ys), max(xs), max(ys))) - - count = 0 - for i in range(len(edges)): - pts_i = edges[i]["points"] - if len(pts_i) < 2: - continue - ei = edges[i] - bi = boxes[i] - for j in range(i + 1, len(edges)): - pts_j = edges[j]["points"] - if len(pts_j) < 2: - continue - bj = boxes[j] - if bi[0] > bj[2] or bj[0] > bi[2] or bi[1] > bj[3] or bj[1] > bi[3]: - continue - ej = edges[j] - shares_endpoint = ( - ei.get("from") == ej.get("from") - or ei.get("to") == ej.get("to") - or ei.get("from") == ej.get("to") - or ei.get("to") == ej.get("from") - ) - for si in range(len(pts_i) - 1): - for sj in range(len(pts_j) - 1): - if _segments_intersect(pts_i[si], pts_i[si + 1], pts_j[sj], pts_j[sj + 1]): - if shares_endpoint: - if _segments_overlap_collinear( - pts_i[si], pts_i[si + 1], pts_j[sj], pts_j[sj + 1] - ): - continue - if _is_fan_trunk_t_junction( - ei, ej, pts_i, si, pts_j, sj - ): - continue - count += 1 - return count - - -def _is_fan_trunk_t_junction(ei, ej, pts_i, si, pts_j, sj): - """True if two same-bundle fan edges meet at the shared trunk as a peel-off - T (structural), as opposed to a genuine 4-way crossing. - - Both edges must be `_fan_locked` onto the SAME bundle (same mode, axis, - trunk coordinate, and shared port). In that bundle the trunk is the line at - ``trunk`` on the bundle's axis; each spoke leaves the trunk perpendicular. - Their segments meet on the trunk line. That meeting is the intended shape, - UNLESS the meeting point is strictly interior to BOTH segments (two spokes - crossing away from the trunk), which is a real defect and returns False. - """ - la, lb = ei.get("_fan_locked"), ej.get("_fan_locked") - if not la or not lb: - return False - if (la["mode"] != lb["mode"] or la["axis"] != lb["axis"] - or la["trunk"] != lb["trunk"] or la["port"] != lb["port"]): - return False # different bundles → treat as unrelated, count normally - a1, a2 = pts_i[si], pts_i[si + 1] - b1, b2 = pts_j[sj], pts_j[sj + 1] - a_h, a_v = a1[1] == a2[1], a1[0] == a2[0] - b_h, b_v = b1[1] == b2[1], b1[0] == b2[0] - if a_h and b_v: - mx, my = b1[0], a1[1] - elif a_v and b_h: - mx, my = a1[0], b1[1] - else: - return False - # The meeting must lie on the bundle's trunk line; otherwise it is two - # spokes meeting away from the trunk (count it). - trunk = la["trunk"] - on_trunk = (mx == trunk) if la["axis"] == "x" else (my == trunk) - if not on_trunk: - return False - # A real 4-way X (interior to both segments) is a defect even on the trunk; - # only an endpoint-on-trunk peel-off is structural. - a_lo_x, a_hi_x = min(a1[0], a2[0]), max(a1[0], a2[0]) - a_lo_y, a_hi_y = min(a1[1], a2[1]), max(a1[1], a2[1]) - b_lo_x, b_hi_x = min(b1[0], b2[0]), max(b1[0], b2[0]) - b_lo_y, b_hi_y = min(b1[1], b2[1]), max(b1[1], b2[1]) - interior_a = a_lo_x < mx < a_hi_x or a_lo_y < my < a_hi_y - interior_b = b_lo_x < mx < b_hi_x or b_lo_y < my < b_hi_y - return not (interior_a and interior_b) - - -# Negative inset = a keep-out margin AROUND each icon. A line running along or -# just outside an icon's edge reads visually as touching/piercing it, so we -# count it as a pierce and push it away. Kept small so legitimate adjacent -# perpendicular stubs are not over-constrained. -_PIERCE_INSET = -9 -_PIERCE_WEIGHT = 4 - - -def _seg_pierces_node(p1, p2, n): - """True if axis-aligned segment p1-p2 passes through (or grazes) node n. - - A negative _PIERCE_INSET expands the test rectangle beyond the icon so - segments running flush against an edge are flagged, matching what reads - visually as touching the icon. - """ - rx, ry = n["x"], n["y"] - rw, rh = n.get("width", 60), n.get("height", n.get("width", 60)) - x0, y0 = rx + _PIERCE_INSET, ry + _PIERCE_INSET - x1, y1 = rx + rw - _PIERCE_INSET, ry + rh - _PIERCE_INSET - ax, ay = p1 - bx, by = p2 - if ax == bx: # vertical - return x0 < ax < x1 and min(ay, by) < y1 and max(ay, by) > y0 - if ay == by: # horizontal - return y0 < ay < y1 and min(ax, bx) < x1 and max(ax, bx) > x0 - return False - - -def _count_node_pierces(edges, nodes): - """Count (edge, node) pairs where an edge passes through a non-endpoint icon.""" - # Pre-compute each node's short id and its expanded pierce box ONCE (this is - # a hot path called thousands of times by the optimizers). The old code - # recomputed nid.rsplit and the box for every (edge, node) pair — millions - # of times on a dense diagram. - node_info = [] - for nid, n in nodes.items(): - short = nid.rsplit(".", 1)[-1] - rx, ry = n["x"], n["y"] - rw = n.get("width", 60) - rh = n.get("height", rw) - x0, y0 = rx + _PIERCE_INSET, ry + _PIERCE_INSET - x1, y1 = rx + rw - _PIERCE_INSET, ry + rh - _PIERCE_INSET - node_info.append((nid, short, n, x0, y0, x1, y1)) - - count = 0 - for e in edges: - pts = e["points"] - if len(pts) < 2: - continue - ignore = {e["from"], e["to"]} - # Edge bounding box for a cheap reject against each node's pierce box. - exs = [p[0] for p in pts] - eys = [p[1] for p in pts] - emnx, emny, emxx, emxy = min(exs), min(eys), max(exs), max(eys) - for nid, short, n, x0, y0, x1, y1 in node_info: - if nid in ignore or short in ignore: - continue - # Box reject: edge bbox vs node's expanded pierce box. - if emnx > x1 or x0 > emxx or emny > y1 or y0 > emxy: - continue - for k in range(len(pts) - 1): - if _seg_pierces_node(pts[k], pts[k + 1], n): - count += 1 - break - return count - - -_GROUP_FRAME_INSET = 2 - - -def _seg_crosses_box(p1, p2, bx, by, bw, bh, inset=0): - """True if axis-aligned segment p1-p2 passes through rectangle (bx,by,bw,bh). - - `inset` shrinks the rectangle so a segment merely running along the frame - edge (or a port landing exactly on it) is not counted as crossing through. - """ - x0, y0 = bx + inset, by + inset - x1, y1 = bx + bw - inset, by + bh - inset - ax, ay = p1 - bx2, by2 = p2 - if ax == bx2: # vertical segment - return x0 < ax < x1 and min(ay, by2) < y1 and max(ay, by2) > y0 - if ay == by2: # horizontal segment - return y0 < ay < y1 and min(ax, bx2) < x1 and max(ax, bx2) > x0 - return False - - -def _count_group_pierces(edges, groups, nodes): - """Count (edge, framed-group) pairs where an edge cuts through a group's - drawn frame without connecting to that group or any icon inside it. - - Only groups with a visible frame (``groupType``) are considered — an - invisible grouping has no box to violate. An edge is exempt for a group if - it starts/ends at that group OR at any of its member icons (those edges are - SUPPOSED to enter the box). Everything else slicing through the frame reads - as a stray line crossing an unrelated container, which looks broken. - """ - if not groups: - return 0 - framed = [(gid, g) for gid, g in groups.items() if g.get("groupType")] - if not framed: - return 0 - count = 0 - for e in edges: - pts = e["points"] - if len(pts) < 2: - continue - efrom = e["from"].rsplit(".", 1)[-1] - eto = e["to"].rsplit(".", 1)[-1] - for gid, g in framed: - gshort = gid.rsplit(".", 1)[-1] - if efrom == gshort or eto == gshort: - continue # edge connects to the group box itself - members = _group_member_ids(nodes, groups, gid) - if efrom in members or eto in members: - continue # edge connects to an icon inside this group - if any(_seg_crosses_box(pts[k], pts[k + 1], g["x"], g["y"], - g["width"], g["height"], _GROUP_FRAME_INSET) - for k in range(len(pts) - 1)): - count += 1 - return count - - -def _count_backwards(edges, nodes): - """Count edges whose first/last segment heads opposite to its port normal. - - A "backwards" segment leaves (or enters) an icon edge pointing back across - the icon — e.g. a bottom port whose first move is upward. The port side is - inferred from the endpoint's position on the node so this works regardless - of label offset (a bottom port sits below the icon's x-span). - """ - count = 0 - for e in edges: - pts = e["points"] - if len(pts) < 2: - continue - src = _find_node(nodes, e["from"]) - dst = _find_node(nodes, e["to"]) - for node, p_port, p_next, leaving in ( - (src, pts[0], pts[1], True), - (dst, pts[-1], pts[-2], False), - ): - if node is None: - continue - side = _port_side(node, p_port) - if side is None: - continue - # Outward normal for the port; the adjacent point must lie on the - # outward side (for a source) — i.e. not back across the icon. - if side == "right" and p_next[0] < p_port[0] - 2: - count += 1 - elif side == "left" and p_next[0] > p_port[0] + 2: - count += 1 - elif side == "bottom" and p_next[1] < p_port[1] - 2: - count += 1 - elif side == "top" and p_next[1] > p_port[1] + 2: - count += 1 - return count - - -def _port_side(node, pt): - """Infer which icon edge a port point sits on (label-offset aware).""" - x, y = pt - cx, cy = node["x"], node["y"] - w = node.get("width", 60) - h = node.get("height", w) - if cx - 2 <= x <= cx + w + 2: - if y >= cy + h - 2: - return "bottom" - if y <= cy + 2: - return "top" - if cy - 2 <= y <= cy + h + 2: - if x <= cx + 2: - return "left" - if x >= cx + w - 2: - return "right" - return None - - -def _edge_free_bend(pts): - """Return ('x'|'y', lo, hi) for the movable middle bend of a 4-point elbow. - - A VHV/HVH path's two middle points share one coordinate (the trunk - position) that can slide between the two endpoints without moving the - port-anchored endpoints or creating diagonals. Returns None for paths - that have no such free bend (straight lines, detours, fan-outs). - """ - if len(pts) != 4: - return None - # HVH: horiz, vert, horiz → middle two points share X (vertical trunk) - if pts[0][1] == pts[1][1] and pts[1][0] == pts[2][0] and pts[2][1] == pts[3][1]: - return ("x", pts[0][0], pts[3][0]) - # VHV: vert, horiz, vert → middle two points share Y (horizontal trunk) - if pts[0][0] == pts[1][0] and pts[1][1] == pts[2][1] and pts[2][0] == pts[3][0]: - return ("y", pts[0][1], pts[3][1]) - return None - - -_BEND_OPT_PASSES = 40 -_BEND_OPT_STEP = 4 -_BEND_OPT_INSET = 6 - - -def _optimize_bends(edges, nodes): - """Slide free elbow bends to minimize global crossings + weighted pierces. - - Coordinate descent: repeatedly try shifting each edge's free middle bend - to a range of candidate positions between its endpoints, keep the best. - Only the two middle points of a 4-point VHV/HVH path move, and only along - the trunk axis, so endpoints stay port-anchored and no diagonals appear. - Skips fan-out edges (they share a deliberate trunk) and detours. - """ - if not edges: - return edges - - def cost(es): - return _count_all_crossings(es) + _PIERCE_WEIGHT * _count_node_pierces(es, nodes) - - cur_cost = cost(edges) - for _ in range(_BEND_OPT_PASSES): - improved = False - for e in edges: - # A locked fan trunk must not move — sliding its free bend is what - # shifts the shared trunk line, which would break the merge the - # user explicitly requested. Leave it fixed. - if e.get("_fan_locked"): - continue - # Fan-out edges start on a shared trunk but are still free to be - # refined individually; optimizing them too reduces crossings - # without breaking axis-alignment (their middle bend is free). - fv = _edge_free_bend(e["points"]) - if not fv: - continue - axis, lo, hi = fv - lo, hi = min(lo, hi), max(lo, hi) - if hi - lo < 2 * _BEND_OPT_INSET: - continue - idx = 0 if axis == "x" else 1 - orig = e["points"][1][idx] - best_val = orig - best_cost = cur_cost - cand = int(lo) + _BEND_OPT_INSET - while cand < int(hi) - _BEND_OPT_INSET: - if cand != orig: - saved1, saved2 = e["points"][1][idx], e["points"][2][idx] - e["points"][1][idx] = cand - e["points"][2][idx] = cand - c = cost(edges) - if c < best_cost: - best_cost = c - best_val = cand - e["points"][1][idx] = saved1 - e["points"][2][idx] = saved2 - cand += _BEND_OPT_STEP - if best_val != orig: - e["points"][1][idx] = best_val - e["points"][2][idx] = best_val - cur_cost = best_cost - improved = True - if not improved: - break - return edges - - -def _optimize_single_bend(cand_pts, edge, edges, nodes): - """Return cand_pts with its single free elbow bend slid to lowest weighted - cost, evaluated against the live edge set (edge temporarily holds cand_pts). - - Used when judging a reselect candidate so we compare its BEST shape, not the - arbitrary mid-bend the router emits. Only the two middle points of a 4-point - VHV/HVH path move, along the trunk axis, so endpoints stay port-anchored. - """ - fv = _edge_free_bend(cand_pts) - if not fv: - return cand_pts - axis, lo, hi = fv - lo, hi = min(lo, hi), max(lo, hi) - if hi - lo < 2 * _BEND_OPT_INSET: - return cand_pts - idx = 0 if axis == "x" else 1 - saved = edge["points"] - best = [list(p) for p in cand_pts] - edge["points"] = best - best_w = _defect_weight((_count_all_crossings(edges), - _count_node_pierces(edges, nodes), - _count_backwards(edges, nodes))) - v = int(lo) + _BEND_OPT_INSET - while v < int(hi) - _BEND_OPT_INSET: - trial = [list(p) for p in cand_pts] - trial[1][idx] = v - trial[2][idx] = v - edge["points"] = trial - w = _defect_weight((_count_all_crossings(edges), - _count_node_pierces(edges, nodes), - _count_backwards(edges, nodes))) - if w < best_w: - best_w = w - best = trial - v += _BEND_OPT_STEP - edge["points"] = saved - return best - - -_SIDE_RESELECT_PASSES = 6 -# (port_index, port_count): center, then 1/4, 1/2, 3/4 along the edge. -_PORT_TRIALS = [(0, 1), (0, 3), (1, 3), (2, 3)] - -# Weights for comparing routing defects when reselecting sides. A pierce is the -# most visually damaging (a line cutting through an icon), so it outweighs a -# crossing; a backwards stub is the mildest. These mirror layout_qa.score(). -_DEFECT_W = (1.0, 1.5, 0.7) # (crossings, pierces, backwards) -# A line cutting through a framed group's box (without connecting to it or any -# icon inside) reads as broken. Weighted like a crossing — bad, but lighter -# than an icon pierce — and only steers the detour pass (it cannot un-pierce a -# box by changing port sides, only by routing around it). -_W_GROUP_PIERCE_ENGINE = 1.0 -# How many extra crossings a side change may introduce to clear a pierce. One -# crossing is an acceptable price to stop a line cutting through an icon. -_RESELECT_CROSS_SLACK = 1 - - -def _defect_weight(score): - """Weighted scalar of a (crossings, pierces, backwards) tuple; lower better.""" - return sum(w * s for w, s in zip(_DEFECT_W, score)) - - -def _candidate_side_pairs(src, dst): - """Geometrically sane (src_side, dst_side) pairs; natural pair first. - - A "sane" side points toward the target — never away from it (which would - force a backwards U-turn). For a target down-and-right of the source this - yields src in {right, bottom} and dst in {left, top}. - """ - s_cx = src["x"] + src.get("width", 60) / 2 - s_cy = src["y"] + src.get("height", 60) / 2 - d_cx = dst["x"] + dst.get("width", 60) / 2 - d_cy = dst["y"] + dst.get("height", 60) / 2 - src_sides, dst_sides = [], [] - if d_cx >= s_cx: - src_sides.append("right") - dst_sides.append("left") - if d_cx <= s_cx: - src_sides.append("left") - dst_sides.append("right") - if d_cy >= s_cy: - src_sides.append("bottom") - dst_sides.append("top") - if d_cy <= s_cy: - src_sides.append("top") - dst_sides.append("bottom") - pairs = [] - nat = _auto_sides(src, dst, None) - pairs.append(nat) - for s in dict.fromkeys(src_sides): - for d in dict.fromkeys(dst_sides): - if (s, d) not in pairs: - pairs.append((s, d)) - return pairs - - -def _is_axis_aligned(pts): - return all( - pts[k][0] == pts[k + 1][0] or pts[k][1] == pts[k + 1][1] - for k in range(len(pts) - 1) - ) - - -def _normalize_path(pts): - """Collapse a polyline's degenerate artifacts in place-safe form. - - Splicing jogs (and stacking several) can leave a path with: - - zero-length segments (consecutive identical points), and - - redundant collinear vertices (three points in a row on one axis), - which both read as a kink at a point that isn't really a corner and which - inflate the crossing count when a stray zero-length stub coincides with - another edge. This removes both without moving any real corner, so the - drawn shape is identical but minimal. Endpoints (pts[0], pts[-1]) are - preserved. Returns a new list; never shortens below 2 points. - """ - if len(pts) < 2: - return [list(p) for p in pts] - # 1) drop consecutive duplicates (zero-length segments) - dedup = [list(pts[0])] - for p in pts[1:]: - if p[0] != dedup[-1][0] or p[1] != dedup[-1][1]: - dedup.append(list(p)) - # 2) drop the middle of any three collinear points (same X or same Y run) - if len(dedup) <= 2: - return dedup - out = [dedup[0]] - for i in range(1, len(dedup) - 1): - a, b, c = out[-1], dedup[i], dedup[i + 1] - collinear_x = a[0] == b[0] == c[0] - collinear_y = a[1] == b[1] == c[1] - if collinear_x or collinear_y: - continue # b lies on the straight run a→c; skip it - out.append(b) - out.append(dedup[-1]) - return out - - -def _entry_exit_ok(pts, src_side, dst_side): - """First segment perpendicular to src edge, last to dst edge (no backwards). - - Rejects degenerate zero-length leading/trailing segments, which would - otherwise read as both horizontal and vertical and let a parallel - (non-perpendicular) run slip through. - """ - if len(pts) < 2: - return False - if pts[0] == pts[1] or pts[-1] == pts[-2]: - return False - first_h = pts[0][1] == pts[1][1] - last_h = pts[-1][1] == pts[-2][1] - src_h = src_side in ("left", "right") - dst_h = dst_side in ("left", "right") - return (first_h == src_h) and (last_h == dst_h) - - -def _edge_pierces(e, nodes): - """True if edge e passes through any non-endpoint icon interior.""" - pts = e["points"] - if len(pts) < 2: - return False - ignore = {e["from"], e["to"]} - for nid, n in nodes.items(): - short = nid.rsplit(".", 1)[-1] - if nid in ignore or short in ignore: - continue - if any(_seg_pierces_node(pts[k], pts[k + 1], n) for k in range(len(pts) - 1)): - return True - return False - - -def _edge_backwards(e, nodes): - """True if edge e has a first/last segment heading against its port normal.""" - return _count_backwards([e], nodes) > 0 - - -def _path_stability(pts): - """Tie-break key: prefer fewer, shorter segments.""" - length = sum( - abs(pts[k + 1][0] - pts[k][0]) + abs(pts[k + 1][1] - pts[k][1]) - for k in range(len(pts) - 1) - ) - return (len(pts), length) - - -def _reselect_sides(edges, nodes, obstacles): - """Remove pierces by re-choosing icon side/port, never by adding segments. - - For each still-piercing edge, re-route via the elbow router using - alternative (src_side, dst_side) pairs and port positions. Accept the - alternative only if it does not raise the global crossing count and - strictly lowers the global (crossings, pierces) tuple. Because crossings - is a hard ceiling, structural pierces (where every alternative raises - crossings) are correctly left untouched. Endpoints stay perpendicular - because they come from _port_point; no diagonals because _elbow_path only - emits H/V segments. - """ - for _ in range(_SIDE_RESELECT_PASSES): - piercing = [ - ei for ei, e in enumerate(edges) - if not e.get("_fanout") and not e.get("_fan_locked") - and len(e["points"]) >= 2 - and (_edge_pierces(e, nodes) or _edge_backwards(e, nodes)) - ] - piercing.sort(key=lambda ei: (edges[ei]["from"], edges[ei]["to"])) - committed = False - - for ei in piercing: - e = edges[ei] - src = _find_node(nodes, e["from"]) - dst = _find_node(nodes, e["to"]) - if not src or not dst: - continue - obs_excl = [o for o in obstacles if o.get("_node") not in (e["from"], e["to"])] - label_h = 30 if src.get("label") else 0 - - base = (_count_all_crossings(edges), _count_node_pierces(edges, nodes), - _count_backwards(edges, nodes)) - orig_pts = e["points"] - best_pts = None - best_score = base - - for (s_side, d_side) in _candidate_side_pairs(src, dst): - for (si, sc) in _PORT_TRIALS: - for (qi, qc) in _PORT_TRIALS: - sp = _port_point(src, s_side, si, sc, label_h) - tp = _port_point(dst, d_side, qi, qc, label_h) - cand = _elbow_path(sp, tp, s_side, d_side, obs_excl) - if not _is_axis_aligned(cand): - continue - if not _entry_exit_ok(cand, s_side, d_side): - continue - # Judge the candidate by its BEST achievable shape: slide - # its free trunk bend to the lowest-cost position before - # scoring. A bottom→top reroute past a row of icons looks - # bad at the default mid-bend but clears everything once - # the trunk is nudged into the gap — evaluate THAT. - cand = _optimize_single_bend(cand, e, edges, nodes) - e["points"] = cand - score = (_count_all_crossings(edges), _count_node_pierces(edges, nodes), - _count_backwards(edges, nodes)) - e["points"] = orig_pts - # A pierce (line through a non-endpoint icon) reads worse - # than a crossing, so judge candidates by a WEIGHTED total - # (pierce 1.5 > cross 1.0 > backwards 0.7) rather than a - # strict crossings-first ceiling. This lets a still-piercing - # edge clear the icon even when doing so adds one crossing, - # matching the layout_qa objective. A guard still rejects - # trades that pile on crossings (more than +_RESELECT_CROSS_SLACK). - if score[0] > base[0] + _RESELECT_CROSS_SLACK: - continue - better = ( - _defect_weight(score) < _defect_weight(best_score) - or (best_pts is not None - and _defect_weight(score) == _defect_weight(best_score) - and _path_stability(cand) < _path_stability(best_pts)) - ) - if better: - best_score = score - best_pts = cand - - if (best_pts is not None - and _defect_weight(best_score) < _defect_weight(base) - and best_score[0] <= base[0] + _RESELECT_CROSS_SLACK): - e["points"] = best_pts - committed = True - - if not committed: - break - return edges - - -_DETOUR_FACE_MARGIN = 18 -_DETOUR_PASSES = 6 -_JOG_ARM_STEP = 12 - - -def _optimize_jog_arm(cand, k, edge, edges, nodes): - """Slide a freshly-spliced jog arm to its lowest-cost parallel position. - - A jog splices 4 points at index k+1: [arm_a, corner_a, corner_b, arm_b]. - The two corners share one free coordinate (the arm's offset from the - pierced segment) — x for a jog off a vertical segment, y for a horizontal - one. The raw candidate hugs the obstacle face; sliding the arm outward can - clear other edges it would otherwise cross. We scan a range of offsets and - keep the one with the lowest weighted defect, evaluated against the live - edge set. Endpoints (arm_a, arm_b) stay put, so the splice remains interior - and axis-aligned. - """ - if len(cand) < k + 5: - return cand - ca, cb = cand[k + 2], cand[k + 3] - # Determine the free axis: corners share x (vertical-seg jog) or y (horiz). - if ca[0] == cb[0]: - axis = 0 # corners share X — slide X - elif ca[1] == cb[1]: - axis = 1 # corners share Y — slide Y - else: - return cand # not a clean bracket - - def weighted(pts_override): - saved = edge["points"] - edge["points"] = pts_override - s = (_count_all_crossings(edges), _count_node_pierces(edges, nodes), - _count_backwards(edges, nodes)) - edge["points"] = saved - return _defect_weight(s) - - base_val = ca[axis] - best = cand - best_w = weighted(cand) - # Search outward on both sides of the current arm offset. - for delta in range(-120, 121, _JOG_ARM_STEP): - if delta == 0: - continue - v = base_val + delta - trial = [list(p) for p in cand] - trial[k + 2][axis] = v - trial[k + 3][axis] = v - if not _is_axis_aligned(trial): - continue - # The arm must not now pierce the very obstacle it was meant to clear, - # nor any other — that is captured by the pierce term in the weight. - w = weighted(trial) - if w < best_w: - best_w = w - best = trial - return best - - -def _jog_candidates(seg_a, seg_b, n): - """Axis-aligned bracket detours around node n for piercing segment a->b. - - Returns replacement point-lists that splice into the segment interior: - a -> (parallel run past one face of n) -> b. Every introduced segment is - horizontal or vertical, and seg_a/seg_b are preserved verbatim, so true - endpoints (when a/b are pts[0]/pts[-1]) never move. A candidate is - discarded when the obstacle extends past the segment's own span (the jog - would need to move an endpoint), guaranteeing the splice stays interior. - """ - nx0, ny0 = n["x"], n["y"] - nx1 = nx0 + n.get("width", 60) - ny1 = ny0 + n.get("height", n.get("width", 60)) - m = _DETOUR_FACE_MARGIN - out = [] - if seg_a[0] == seg_b[0]: # vertical segment at x=X -> jog left/right - x = seg_a[0] - y_lo, y_hi = min(seg_a[1], seg_b[1]), max(seg_a[1], seg_b[1]) - # Bracket arms run parallel just past the obstacle's vertical extent, - # clamped to stay strictly inside the segment span so the splice never - # moves an endpoint. If the obstacle protrudes past an end, clamp the - # arm to that endpoint (collapsing the stub to zero length there). - b_lo = max(y_lo, ny0 - m) - b_hi = min(y_hi, ny1 + m) - if b_lo >= b_hi: - return out # no overlap to bracket - for cx in (nx0 - m, nx1 + m): - out.append([list(seg_a), [x, b_lo], [cx, b_lo], [cx, b_hi], [x, b_hi], list(seg_b)]) - elif seg_a[1] == seg_b[1]: # horizontal segment at y=Y -> jog up/down - y = seg_a[1] - x_lo, x_hi = min(seg_a[0], seg_b[0]), max(seg_a[0], seg_b[0]) - b_lo = max(x_lo, nx0 - m) - b_hi = min(x_hi, nx1 + m) - if b_lo >= b_hi: - return out - for cy in (ny0 - m, ny1 + m): - out.append([list(seg_a), [b_lo, y], [b_lo, cy], [b_hi, cy], [b_hi, y], list(seg_b)]) - return out - - -def _detour_around_pierces(edges, nodes, groups=None): - """Splice obstacle jogs to clear pierces no side/port choice can fix. - - Obstacles are both non-endpoint ICONS and framed GROUP boxes that an edge - cuts through without connecting to (group-frame pierce). The same bracket - jog clears either — a box is just a wider obstacle. Group pierces feed the - weighted cost so a detour around a frame is taken when it does not cost more - crossings/icon-pierces than it saves. - - Greedy, one commit at a time, re-measuring the full live edge set after - every tentative change. A jog is committed only if the global - (crossings, pierces) tuple strictly improves AND crossings does not rise. - This makes crossings monotone non-increasing — the separate-pass blow-up - (where locally-accepted jogs interacted to raise global crossings) cannot - recur. Structural pierces, whose every jog raises crossings, are left. - """ - if not edges: - return edges - - # Framed groups an edge may need to detour around (box obstacles). - framed = [(gid, g) for gid, g in (groups or {}).items() if g.get("groupType")] - - def cost(es): - gp = _count_group_pierces(es, groups, nodes) if framed else 0 - return (_count_all_crossings(es), - _count_node_pierces(es, nodes) + _W_GROUP_PIERCE_ENGINE * gp, - _count_backwards(es, nodes)) - - for _ in range(_DETOUR_PASSES): - cur = cost(edges) - if cur[1] == 0: - break - improved = False - - for e in edges: - # A locked fan trunk must keep its shape — splicing a jog into it - # would bend the shared trunk and break the merge. Skip it; other - # edges detour around it instead. - if e.get("_fan_locked"): - continue - # Fan-out edges are eligible: a jog around an obstacle does not - # break the shared-trunk concept, and the global gate below only - # commits it when it strictly helps. - pts = e["points"] - if len(pts) < 2: - continue - ignore = {e["from"], e["to"]} - # Box obstacles this edge must avoid: framed groups it neither - # connects to nor has a member endpoint in. - efrom = e["from"].rsplit(".", 1)[-1] - eto = e["to"].rsplit(".", 1)[-1] - box_obstacles = [] - for gid, g in framed: - gshort = gid.rsplit(".", 1)[-1] - if efrom == gshort or eto == gshort: - continue - members = _group_member_ids(nodes, groups, gid) - if efrom in members or eto in members: - continue - box_obstacles.append(g) - # A box detour is only worth taking if it removes ALL frame pierces - # this edge causes — a partial detour that still clips a box just - # adds wire/bends for a still-broken look (the microservices - # bus-through-services case). Icon-pierce jogs keep their original - # partial-improvement behaviour. - base = cost(edges) - best_pts = None - best_score = base - # Scan each segment for a pierced obstacle; build jog candidates. - for k in range(len(pts) - 1): - seg_a, seg_b = pts[k], pts[k + 1] - # Obstacles for this segment: non-endpoint icons it pierces, plus - # framed group boxes it cuts through. - hit_obs = [] - for nid, n in nodes.items(): - short = nid.rsplit(".", 1)[-1] - if nid in ignore or short in ignore: - continue - if _seg_pierces_node(seg_a, seg_b, n): - hit_obs.append((n, False)) - for g in box_obstacles: - if _seg_crosses_box(seg_a, seg_b, g["x"], g["y"], - g["width"], g["height"], _GROUP_FRAME_INSET): - hit_obs.append((g, True)) - for n, is_box in hit_obs: - for repl in _jog_candidates(seg_a, seg_b, n): - cand = pts[:k + 1] + repl[1:-1] + pts[k + 1:] - if not _is_axis_aligned(cand): - continue - # The raw jog hugs the obstacle's face; that arm position - # may cross other edges. Slide the jog arm to its best - # position FIRST, then judge — mirrors evaluating a config - # by its post-bend-optimization quality, not its raw form. - cand = _optimize_jog_arm(cand, k, e, edges, nodes) - # Strip zero-length / collinear artifacts the splice (or - # a previously-committed jog stacked on this segment) may - # have left, so a stray stub can't fake a crossing and the - # committed shape is minimal. - cand = _normalize_path(cand) - saved = e["points"] - e["points"] = cand - score = cost(edges) - # A box detour must fully clear this edge's frame - # pierces; a partial escape is rejected so we never - # commit a longer, still-piercing route. - box_pierce_after = (_count_group_pierces([e], groups, nodes) - if box_obstacles else 0) - e["points"] = saved - # Box detour: accept ONLY when it fully clears this - # edge of every frame it cut through. A still-clipping - # detour (after > 0) is the structural case the user - # must fix by restructuring — leave it untouched and let - # the warning flag it. - if is_box and box_pierce_after != 0: - continue - # Judge by weighted defect (pierce 1.5 > cross 1.0): a jog - # may add up to _RESELECT_CROSS_SLACK crossings to lift a - # line off an icon it cuts through, which reads far worse - # than a crossing. Mirrors _reselect_sides. - if score[0] > base[0] + _RESELECT_CROSS_SLACK: - continue - if _defect_weight(score) < _defect_weight(best_score) or ( - best_pts is not None - and _defect_weight(score) == _defect_weight(best_score) - and _path_stability(cand) < _path_stability(best_pts) - ): - best_score = score - best_pts = cand - if (best_pts is not None - and _defect_weight(best_score) < _defect_weight(base) - and best_score[0] <= base[0] + _RESELECT_CROSS_SLACK): - e["points"] = best_pts - improved = True - - if not improved: - break - return edges - - -_MIN_BEND_SEPARATION = 40 - - -def _resolve_crossing_search(edges, i, si, j, sj): - """Try all candidate shifts for both crossing edges and pick the best. - - Scoring: (crossings, -min_bend_separation, displacement) - 1. Minimize total crossings (most important) - 2. Maximize minimum distance between parallel bends (visual clarity) - 3. Minimize displacement from original position (stability) - """ - import copy - - pts_i = edges[i]["points"] - pts_j = edges[j]["points"] - a1, a2 = pts_i[si], pts_i[si + 1] - b1, b2 = pts_j[sj], pts_j[sj + 1] - - current_crossings = _count_all_crossings(edges) - current_separation = _min_bend_separation(edges) - best_score = (current_crossings, -current_separation, 0) - best_patch = None - - candidates = [] - for edge_idx, seg_idx, pt1, pt2 in [(i, si, a1, a2), (j, sj, b1, b2)]: - is_vert = pt1[0] == pt2[0] - is_horiz = pt1[1] == pt2[1] - if is_vert: - for delta in _BEND_CANDIDATES: - candidates.append((edge_idx, "x", seg_idx, delta)) - if is_horiz: - for delta in _BEND_CANDIDATES: - candidates.append((edge_idx, "y", seg_idx, delta)) - - for edge_idx, axis, seg, delta in candidates: - test_edges = copy.deepcopy(edges) - pts = test_edges[edge_idx]["points"] - - if axis == "x": - _apply_bend_shift_x(pts, seg, delta) - else: - _apply_bend_shift_y(pts, seg, delta) - - crossings = _count_all_crossings(test_edges) - separation = _min_bend_separation(test_edges) - score = (crossings, -separation, abs(delta)) - - if score < best_score: - best_score = score - best_patch = (edge_idx, copy.deepcopy(test_edges[edge_idx]["points"])) - - if best_patch is not None: - edge_idx, new_pts = best_patch - edges[edge_idx]["points"] = new_pts - - -def _min_bend_separation(edges): - """Calculate the minimum distance between parallel bend segments. - - Checks all pairs of vertical bends (same-ish Y range) for X separation, - and all pairs of horizontal bends (same-ish X range) for Y separation. - Returns the minimum separation found (larger = better visual clarity). - """ - vertical_bends = [] - horizontal_bends = [] - - for e in edges: - pts = e["points"] - if len(pts) < 4: - continue - for k in range(len(pts) - 1): - p1, p2 = pts[k], pts[k + 1] - if p1[0] == p2[0] and abs(p1[1] - p2[1]) > 10: - y_min, y_max = min(p1[1], p2[1]), max(p1[1], p2[1]) - vertical_bends.append((p1[0], y_min, y_max)) - elif p1[1] == p2[1] and abs(p1[0] - p2[0]) > 10: - x_min, x_max = min(p1[0], p2[0]), max(p1[0], p2[0]) - horizontal_bends.append((p1[1], x_min, x_max)) - - min_sep = 9999 - - for a in range(len(vertical_bends)): - for b in range(a + 1, len(vertical_bends)): - ax, ay_min, ay_max = vertical_bends[a] - bx, by_min, by_max = vertical_bends[b] - overlap_y = min(ay_max, by_max) - max(ay_min, by_min) - if overlap_y > 10: - sep = abs(ax - bx) - if sep < min_sep: - min_sep = sep - - for a in range(len(horizontal_bends)): - for b in range(a + 1, len(horizontal_bends)): - ay, ax_min, ax_max = horizontal_bends[a] - by, bx_min, bx_max = horizontal_bends[b] - overlap_x = min(ax_max, bx_max) - max(ax_min, bx_min) - if overlap_x > 10: - sep = abs(ay - by) - if sep < min_sep: - min_sep = sep - - return min_sep - - -def _separate_close_bends(edges): - """Spread parallel bends that are too close, distributing them evenly. - - Groups vertical bends that share a similar X position (within _MIN_BEND_SEPARATION) - AND whose Y ranges are adjacent or overlapping. Spreads their X positions evenly - with _MIN_BEND_SEPARATION between each. - Does not introduce new crossings. - """ - import copy - - # Collect all vertical bend segments: (edge_idx, seg_idx, x, y_min, y_max) - v_bends = [] - for ei, e in enumerate(edges): - if e.get("_fanout"): - continue - pts = e["points"] - for k in range(len(pts) - 1): - if pts[k][0] == pts[k + 1][0] and abs(pts[k][1] - pts[k + 1][1]) > 10: - y_min = min(pts[k][1], pts[k + 1][1]) - y_max = max(pts[k][1], pts[k + 1][1]) - v_bends.append((ei, k, pts[k][0], y_min, y_max)) - - # Group bends that are close in X AND adjacent/overlapping in Y - # BUT: only group bends from DIFFERENT source nodes. - # Bends from the same source should be aligned (not separated). - used = set() - groups = [] - for a in range(len(v_bends)): - if a in used: - continue - group = [a] - group_y_min = v_bends[a][3] - group_y_max = v_bends[a][4] - src_a = edges[v_bends[a][0]]["from"] - for b in range(a + 1, len(v_bends)): - if b in used: - continue - ei_b = v_bends[b][0] - src_b = edges[ei_b]["from"] - # Skip if same source — those should stay aligned - if src_b == src_a: - continue - _, _, bx, by_min, by_max = v_bends[b] - group_x_avg = sum(v_bends[idx][2] for idx in group) // len(group) - if abs(bx - group_x_avg) >= _MIN_BEND_SEPARATION: - continue - gap = max(by_min - group_y_max, group_y_min - by_max) - if gap < 50: - group.append(b) - group_y_min = min(group_y_min, by_min) - group_y_max = max(group_y_max, by_max) - if len(group) < 2: - continue - used.update(group) - groups.append(group) - - # Spread each group evenly - for group in groups: - group_bends = [(v_bends[idx], idx) for idx in group] - group_bends.sort(key=lambda t: (t[0][3] + t[0][4]) / 2) - center_x = sum(v[0][2] for v in group_bends) // len(group_bends) - spread_total = _MIN_BEND_SEPARATION * (len(group_bends) - 1) - start_x = center_x - spread_total // 2 - - current_crossings = _count_all_crossings(edges) - for slot, (bend_info, _) in enumerate(group_bends): - ei, seg_k, old_x, _, _ = bend_info - new_x = start_x + slot * _MIN_BEND_SEPARATION - if new_x == old_x: - continue - test_edges = copy.deepcopy(edges) - delta = new_x - old_x - _apply_bend_shift_x(test_edges[ei]["points"], seg_k, delta) - if _count_all_crossings(test_edges) <= current_crossings: - _apply_bend_shift_x(edges[ei]["points"], seg_k, delta) - - # Align bends from the same source to a single X position - _align_same_source_bends(edges) - - # Also spread close horizontal segments - _separate_close_horizontal_segments(edges) - - -def _align_same_source_bends(edges): - """Align bends from the same source node to a single X (or Y) position. - - When multiple edges fan out from the same node, their vertical bends - should share the same X so they look like a clean tree branch. - Only aligns if it doesn't introduce new crossings. - """ - import copy - - # Group edges by source - src_groups = {} - for ei, e in enumerate(edges): - pts = e["points"] - if len(pts) < 4: - continue - src_groups.setdefault(e["from"], []).append(ei) - - current_crossings = _count_all_crossings(edges) - - for src, edge_indices in src_groups.items(): - if len(edge_indices) < 2: - continue - - # Skip alignment if start Y positions differ (fan-out with distributed ports) - start_ys = [edges[ei]["points"][0][1] for ei in edge_indices] - if max(start_ys) - min(start_ys) > 10: - continue - - # Collect vertical bend X positions for these edges - bend_xs = [] - for ei in edge_indices: - pts = edges[ei]["points"] - for k in range(len(pts) - 1): - if pts[k][0] == pts[k + 1][0] and abs(pts[k][1] - pts[k + 1][1]) > 5: - bend_xs.append((ei, k, pts[k][0])) - break - - if len(bend_xs) < 2: - continue - - # All already aligned? - xs = [x for _, _, x in bend_xs] - if max(xs) - min(xs) <= 5: - continue - - # Try aligning to the median X - target_x = sorted(xs)[len(xs) // 2] - - # Test: align all to target_x - test_edges = copy.deepcopy(edges) - for ei, k, old_x in bend_xs: - if old_x != target_x: - delta = target_x - old_x - _apply_bend_shift_x(test_edges[ei]["points"], k, delta) - - if _count_all_crossings(test_edges) <= current_crossings: - for ei, k, old_x in bend_xs: - if old_x != target_x: - delta = target_x - old_x - _apply_bend_shift_x(edges[ei]["points"], k, delta) - - # Same for destination (fan-in): align bends going to the same target - dst_groups = {} - for ei, e in enumerate(edges): - pts = e["points"] - if len(pts) < 4: - continue - dst_groups.setdefault(e["to"], []).append(ei) - - current_crossings = _count_all_crossings(edges) - - for dst, edge_indices in dst_groups.items(): - if len(edge_indices) < 2: - continue - - bend_xs = [] - for ei in edge_indices: - pts = edges[ei]["points"] - for k in range(len(pts) - 1): - if pts[k][0] == pts[k + 1][0] and abs(pts[k][1] - pts[k + 1][1]) > 5: - bend_xs.append((ei, k, pts[k][0])) - break - - if len(bend_xs) < 2: - continue - - xs = [x for _, _, x in bend_xs] - if max(xs) - min(xs) <= 5: - continue - - target_x = sorted(xs)[len(xs) // 2] - - test_edges = copy.deepcopy(edges) - for ei, k, old_x in bend_xs: - if old_x != target_x: - delta = target_x - old_x - _apply_bend_shift_x(test_edges[ei]["points"], k, delta) - - if _count_all_crossings(test_edges) <= current_crossings: - for ei, k, old_x in bend_xs: - if old_x != target_x: - delta = target_x - old_x - _apply_bend_shift_x(edges[ei]["points"], k, delta) - - -def _separate_close_horizontal_segments(edges): - """Detect horizontal segments at nearly the same Y with overlapping X range. - - When two horizontal segments from different edges are within - _MIN_BEND_SEPARATION/2 in Y and overlap in X, shift one edge's bend - to create visual separation. - """ - import copy - - # Collect all horizontal segments: (edge_idx, seg_idx, y, x_min, x_max) - h_segs = [] - for ei, e in enumerate(edges): - pts = e["points"] - for k in range(len(pts) - 1): - if pts[k][1] == pts[k + 1][1] and abs(pts[k][0] - pts[k + 1][0]) > 20: - x_min = min(pts[k][0], pts[k + 1][0]) - x_max = max(pts[k][0], pts[k + 1][0]) - h_segs.append((ei, k, pts[k][1], x_min, x_max)) - - current_crossings = _count_all_crossings(edges) - adjusted = set() - for a in range(len(h_segs)): - for b in range(a + 1, len(h_segs)): - ei_a, k_a, y_a, xmin_a, xmax_a = h_segs[a] - ei_b, k_b, y_b, xmin_b, xmax_b = h_segs[b] - if ei_a == ei_b: - continue - y_diff = abs(y_a - y_b) - if y_diff >= _MIN_BEND_SEPARATION // 2: - continue - overlap = min(xmax_a, xmax_b) - max(xmin_a, xmin_b) - if overlap <= 20: - continue - - # Try shifting either edge's vertical bend X to shorten/lengthen - # the horizontal segment so they no longer overlap in X. - resolved = False - for ei, k in [(ei_a, k_a), (ei_b, k_b)]: - if ei in adjusted or resolved: - continue - pts = edges[ei]["points"] - # Find the vertical bend in this edge - for vk in range(len(pts) - 1): - if pts[vk][0] == pts[vk + 1][0] and abs(pts[vk][1] - pts[vk + 1][1]) > 5: - # Try shifting this bend X to reduce horizontal overlap - other_xmin = xmin_b if ei == ei_a else xmin_a - other_xmax = xmax_b if ei == ei_a else xmax_a - # Shift bend to just before or after the other segment - for delta in [-60, -40, 60, 40, -80, 80, -100, 100]: - test_edges = copy.deepcopy(edges) - _apply_bend_shift_x(test_edges[ei]["points"], vk, delta) - # Check: overlap reduced AND no new crossings - new_crossings = _count_all_crossings(test_edges) - # Recalculate overlap - new_pts = test_edges[ei]["points"] - for nk in range(len(new_pts) - 1): - if new_pts[nk][1] == new_pts[nk + 1][1] and abs(new_pts[nk][0] - new_pts[nk + 1][0]) > 20: - new_xmin = min(new_pts[nk][0], new_pts[nk + 1][0]) - new_xmax = max(new_pts[nk][0], new_pts[nk + 1][0]) - if abs(new_pts[nk][1] - (y_b if ei == ei_a else y_a)) < _MIN_BEND_SEPARATION // 2: - new_overlap = min(new_xmax, other_xmax) - max(new_xmin, other_xmin) - if new_overlap <= 20 and new_crossings <= current_crossings: - _apply_bend_shift_x(edges[ei]["points"], vk, delta) - adjusted.add(ei) - resolved = True - break - if resolved: - break - break - - -def _apply_bend_shift_x(points, seg_idx, delta): - """Shift the vertical bend at seg_idx by delta on the X axis. - - Never moves the first or last point (port-anchored endpoints). - """ - if len(points) < 3: - return - p1 = points[seg_idx] - p2 = points[min(seg_idx + 1, len(points) - 1)] - if p1[0] == p2[0]: - target_x = p1[0] - elif seg_idx > 0 and points[seg_idx - 1][0] == p1[0]: - target_x = p1[0] - else: - target_x = p1[0] - - for i, pt in enumerate(points): - if i == 0 or i == len(points) - 1: - continue - if pt[0] == target_x: - pt[0] += delta - - -def _apply_bend_shift_y(points, seg_idx, delta): - """Shift the horizontal bend at seg_idx by delta on the Y axis. - - Never moves the first or last point (port-anchored endpoints). - Only shifts points that are part of an internal horizontal segment - (not adjacent to the start/end points). - """ - if len(points) < 4: - return - p1 = points[seg_idx] - p2 = points[min(seg_idx + 1, len(points) - 1)] - if p1[1] == p2[1]: - target_y = p1[1] - elif seg_idx > 0 and points[seg_idx - 1][1] == p1[1]: - target_y = p1[1] - else: - target_y = p1[1] - - # Don't shift if target_y matches start or end Y (would break port alignment) - if target_y == points[0][1] or target_y == points[-1][1]: - return - - for i, pt in enumerate(points): - if i == 0 or i == len(points) - 1: - continue - if pt[1] == target_y: - pt[1] += delta - - -def _update_bend_x(points, old_x, new_x): - """Update bend X coordinate in a 4-point elbow path.""" - for pt in points: - if abs(pt[0] - old_x) < 3: - pt[0] = new_x - - -_FAN_BEND_MARGIN = 30 -# How far past a framed group's edge the fan trunk is pushed so the split/merge -# happens clearly outside the box, not flush against the frame. -_FAN_GROUP_CLEARANCE = 22 - - -def _enclosing_framed_group(groups, nodes, node_id): - """Return the geometry of the framed group that directly encloses node_id. - - A fan hub that lives inside a drawn box should split/merge OUTSIDE that box. - We find the framed (groupType) group whose member set contains node_id and, - if several nest, pick the SMALLEST (innermost) by area — that is the frame - the trunk must clear first. Returns the group dict or None. - """ - if not groups: - return None - short = node_id.rsplit(".", 1)[-1] - best = None - for gid, g in groups.items(): - if not g.get("groupType"): - continue - members = _group_member_ids(nodes, groups, gid) - if short in members: - area = g["width"] * g["height"] - if best is None or area < best[0]: - best = (area, g) - return best[1] if best else None - - -def _push_trunk_outside_group(trunk_v, side, vertical, nearest, hbox): - """Shift a fan trunk coordinate to just past the hub's enclosing frame. - - The trunk is the shared line where the bundle splits (fan-out) or merges - (fan-in). When the hub sits inside a framed box, a trunk flush against the - icon still bends inside the frame. Push it past the frame edge it exits - through (by _FAN_GROUP_CLEARANCE), but clamp so it never reaches/over­shoots - the nearest spoke — leaving the spoke side of the gap for the actual fan. - No-op when there is no enclosing frame or the push would cross the spoke. - """ - if hbox is None: - return trunk_v - if vertical: - edge = hbox["y"] + hbox["height"] if side == "bottom" else hbox["y"] - else: - edge = hbox["x"] + hbox["width"] if side == "right" else hbox["x"] - if side in ("right", "bottom"): - target = edge + _FAN_GROUP_CLEARANCE - # only push outward, and stay short of the nearest spoke - if target > trunk_v and target < nearest: - return target - else: # left / top — frame edge is on the smaller-coordinate side - target = edge - _FAN_GROUP_CLEARANCE - if target < trunk_v and target > nearest: - return target - return trunk_v - - -def _fan_side_vote(edges, conn_sides, indices, mode, nodes=None, groups=None): - """Pick the single shared hub side for a fan group from GEOMETRY. - - The hub end (src for fan-out, dst for fan-in) must agree on ONE side so all - edges leave/enter through one unified port. We choose the box edge that - faces the spokes' centroid: e.g. a hub directly BELOW a row of spokes is - entered through its TOP. This is far more robust than the old majority vote - over per-edge router sides, which picked "right" for a hub sitting squarely - below its sources (each spoke saw a different diagonal direction). - """ - hub_id = edges[indices[0]]["from"] if mode == "fan_out" else edges[indices[0]]["to"] - hub, _ = _find_endpoint(nodes or {}, groups or {}, hub_id) - spokes = [] - for i in indices: - sid = edges[i]["to"] if mode == "fan_out" else edges[i]["from"] - s, _ = _find_endpoint(nodes or {}, groups or {}, sid) - if s is not None: - spokes.append(s) - if hub is not None and spokes: - hcx = hub["x"] + hub["width"] / 2 - hcy = hub["y"] + hub["height"] / 2 - scx = sum(s["x"] + s["width"] / 2 for s in spokes) / len(spokes) - scy = sum(s["y"] + s["height"] / 2 for s in spokes) / len(spokes) - dx, dy = scx - hcx, scy - hcy # direction from hub toward spokes - if abs(dx) >= abs(dy): - return "right" if dx > 0 else "left" - return "bottom" if dy > 0 else "top" - - # Fallback: majority vote over router-chosen sides. - pref = {"right": 0, "left": 1, "bottom": 2, "top": 3} - votes = {} - for i in indices: - _, _, src_side, dst_side = conn_sides[i] - side = src_side if mode == "fan_out" else dst_side - if side: - votes[side] = votes.get(side, 0) + 1 - if not votes: - return "right" - return sorted(votes.items(), key=lambda kv: (-kv[1], pref.get(kv[0], 9)))[0][0] - - -def _rewrite_fan(edges, conn_sides, indices, mode, nodes=None, groups=None): - """Force a fan-out/fan-in group onto a unified port and a shared trunk. - - The merge is a hard constraint (the LLM asked for it), so we rebuild every - edge in the group from scratch as a clean 4-point elbow: - - one shared port on the hub node (computed from node geometry, centered), - - a shared trunk coordinate (all edges bend at the same line), - - the spoke then peels off to each individual target/source. - The hub may be a NODE or a GROUP (box) — both expose x/y/width/height, so a - group hub gets a single shared port on its box edge just like a node. Edges - that had become detours (len>4) are rebuilt too. Each edge is tagged - `_fan_locked` so downstream optimizers don't undo the alignment. - """ - if not indices: - return - side = _fan_side_vote(edges, conn_sides, indices, mode, nodes, groups) - vertical = side in ("top", "bottom") - - # Resolve the hub (shared end) — node OR group — and its geometry. - hub_id = edges[indices[0]]["from"] if mode == "fan_out" else edges[indices[0]]["to"] - hub, hub_is_group = _find_endpoint(nodes or {}, groups or {}, hub_id) - - # Unified port point on the hub edge, centered along that edge. - if hub is not None: - hx, hy, hw, hh = hub["x"], hub["y"], hub["width"], hub["height"] - # A group port sits on the box edge (no label band offset). - label_h = 0 if hub_is_group else (30 if hub.get("label") else 0) - if side == "right": - port = [hx + hw, hy + hh // 2] - elif side == "left": - port = [hx, hy + hh // 2] - elif side == "bottom": - port = [hx + hw // 2, hy + hh + label_h] - else: # top - port = [hx + hw // 2, hy] - else: - # Fall back to averaging the existing ports if geometry is unavailable. - ends = [edges[j]["points"][0] if mode == "fan_out" else edges[j]["points"][-1] - for j in indices] - port = [sum(p[0] for p in ends) // len(ends), sum(p[1] for p in ends) // len(ends)] - - # Shared trunk coordinate: a line in the GAP between the hub port and the - # nearest spoke. It must stay strictly between the two — if the gap is - # narrower than the preferred margin, fall back to the midpoint rather than - # overshooting the spoke (which would drive the trunk into the spoke icons, - # the bug that made stacked fan-outs pierce their targets). - spoke_ends = [edges[j]["points"][-1] if mode == "fan_out" else edges[j]["points"][0] - for j in indices] - - def _gap_trunk(p0, nearest): - # p0 = hub port coordinate, nearest = closest spoke coordinate. - lo, hi = (p0, nearest) if p0 <= nearest else (nearest, p0) - mid = (p0 + nearest) // 2 - if hi - lo <= 2 * _FAN_BEND_MARGIN: - return mid # gap too tight for the margin → sit in the middle - # otherwise sit _FAN_BEND_MARGIN away from the hub, toward the spoke - return p0 + _FAN_BEND_MARGIN if p0 < nearest else p0 - _FAN_BEND_MARGIN - - if vertical: - spoke_vs = [p[1] for p in spoke_ends] - nearest = min(spoke_vs) if side == "bottom" else max(spoke_vs) - trunk_v = _gap_trunk(port[1], nearest) - else: - spoke_hs = [p[0] for p in spoke_ends] - nearest = min(spoke_hs) if side == "right" else max(spoke_hs) - trunk_v = _gap_trunk(port[0], nearest) - - # Keep the split/merge OUTSIDE the hub's framed group. When the hub icon - # lives inside a drawn box (e.g. EventBridge inside "Orchestration"), a - # trunk sitting just past the icon still bends WHILE inside the frame, so - # the fan visibly branches within an unrelated container. Push the trunk - # past the frame edge it exits through (plus a margin) so the bundle leaves - # the box as one line and only fans out beyond it — but never past the - # nearest spoke (that would drive the trunk into the targets). Only applies - # when the hub is a NODE enclosed by a framed group on the exit side. - if not hub_is_group and groups: - trunk_v = _push_trunk_outside_group( - trunk_v, side, vertical, nearest, - _enclosing_framed_group(groups, nodes, hub_id)) - - # The spoke nodes (the N individual ends) must ALSO leave/enter through a - # consistent edge — the one facing the trunk. A fan-in to a trunk BELOW the - # agents means every agent exits its BOTTOM edge (not whichever side the - # router first picked, which left planner exiting "right" and coder "left"). - # The spoke side is the side facing the trunk: opposite the hub side for the - # spoke's own port normal. - def spoke_port(node, sside, is_group): - nx, ny, nw, nh = node["x"], node["y"], node["width"], node["height"] - nlabel_h = 0 if is_group else (30 if node.get("label") else 0) - if sside == "bottom": - return [nx + nw // 2, ny + nh + nlabel_h] - if sside == "top": - return [nx + nw // 2, ny] - if sside == "right": - return [nx + nw, ny + nh // 2] - return [nx, ny + nh // 2] # left - - for i in indices: - # spoke end = the per-edge individual end (target for fan-out, source for - # fan-in); it may itself be a node OR a group. - spoke_id = edges[i]["to"] if mode == "fan_out" else edges[i]["from"] - spoke_node, spoke_is_group = _find_endpoint(nodes or {}, groups or {}, spoke_id) - pts = edges[i]["points"] - - # Decide which spoke edge faces the trunk. The trunk is a line on the - # `side` axis relative to the hub; the spoke must exit toward it. - if vertical: - # trunk is a horizontal line at y=trunk_v; spoke exits bottom if it - # sits above the trunk, else top. - ref = (spoke_node["y"] + spoke_node["height"] // 2) if spoke_node else pts[0][1] - s_side = "bottom" if trunk_v >= ref else "top" - else: - ref = (spoke_node["x"] + spoke_node["width"] // 2) if spoke_node else pts[0][0] - s_side = "right" if trunk_v >= ref else "left" - - if spoke_node is not None: - spoke_pt = spoke_port(spoke_node, s_side, spoke_is_group) - else: - spoke_pt = list(pts[-1] if mode == "fan_out" else pts[0]) - - if mode == "fan_out": - tgt = spoke_pt - if vertical: - edges[i]["points"] = [list(port), [port[0], trunk_v], [tgt[0], trunk_v], tgt] - else: - edges[i]["points"] = [list(port), [trunk_v, port[1]], [trunk_v, tgt[1]], tgt] - else: # fan_in - srcp = spoke_pt - if vertical: - edges[i]["points"] = [srcp, [srcp[0], trunk_v], [port[0], trunk_v], list(port)] - else: - edges[i]["points"] = [srcp, [trunk_v, srcp[1]], [trunk_v, port[1]], list(port)] - # Lock the trunk: downstream optimizers must not move the shared - # coordinate. The spoke (3rd point toward the individual end) stays - # free to be nudged if needed. - edges[i]["_fan_locked"] = { - "mode": mode, - "axis": "y" if vertical else "x", - "trunk": trunk_v, - "port": list(port), - } - - -def _find_group_for(node_id, node_group): - """Find parent group id for a node, handling qualified ids.""" - if node_id in node_group: - return node_group[node_id] - for nid, gid in node_group.items(): - if nid.endswith("." + node_id): - return gid - return None - - -def _find_node(nodes, node_id): - if node_id in nodes: - return nodes[node_id] - for nid, n in nodes.items(): - if nid.endswith("." + node_id): - return n - return None - - -def _find_group(groups, gid): - """Resolve a group by id (qualified or short), if groups is provided.""" - if not groups: - return None - if gid in groups: - return groups[gid] - for g_id, g in groups.items(): - if g_id.endswith("." + gid): - return g - return None - - -def _find_endpoint(nodes, groups, eid): - """Resolve a connection endpoint that may be a node OR a group. - - Returns (geom, is_group): geom is a dict with x/y/width/height (both nodes - and laid-out groups carry these), is_group flags a group target so callers - can treat the box edge as the port and skip the group's own children as - obstacles. A node takes precedence over a group with the same id. - """ - n = _find_node(nodes, eid) - if n is not None: - return n, False - g = _find_group(groups, eid) - if g is not None: - return g, True - return None, False - - -def _group_qualified_id(groups, gid): - """Return the fully-qualified key of group gid in the flat groups dict.""" - if not groups: - return None - if gid in groups: - return gid - for g_id in groups: - if g_id.endswith("." + gid): - return g_id - return None - - -def _group_member_ids(nodes, groups, gid): - """Short ids of all leaf nodes inside group gid (for obstacle exclusion). - - The collected `groups` dict stores children as qualified id strings, and - every leaf node inside the group is a key in `nodes` prefixed by the - group's qualified id. We match on that prefix. - """ - qid = _group_qualified_id(groups, gid) - if not qid: - return set() - prefix = qid + "." - out = set() - for nid in nodes: - if nid == qid or nid.startswith(prefix): - out.add(nid.rsplit(".", 1)[-1]) - return out - - -def _auto_sides(src, dst, group_direction=None): - if group_direction == "horizontal": - sx = src["x"] + src["width"] // 2 - dx = dst["x"] + dst["width"] // 2 - return ("right", "left") if dx > sx else ("left", "right") - if group_direction == "vertical": - sy = src["y"] + src["height"] // 2 - dy = dst["y"] + dst["height"] // 2 - return ("bottom", "top") if dy > sy else ("top", "bottom") - sx = src["x"] + src["width"] // 2 - sy = src["y"] + src["height"] // 2 - dx = dst["x"] + dst["width"] // 2 - dy = dst["y"] + dst["height"] // 2 - diffx, diffy = dx - sx, dy - sy - # Prefer vertical when dx and dy are close (within 30% ratio) - # This produces more natural top-down flow in diagrams - if abs(diffy) > 0 and abs(diffx) / abs(diffy) < 1.3: - return ("bottom", "top") if diffy > 0 else ("top", "bottom") - if abs(diffx) >= abs(diffy): - return ("right", "left") if diffx > 0 else ("left", "right") - else: - return ("bottom", "top") if diffy > 0 else ("top", "bottom") - - -def _port_point(node, side, index, count, label_h): - x, y, w, h = node["x"], node["y"], node["width"], node["height"] - t = 0.5 if count <= 1 else (index + 1) / (count + 1) - if side == "right": - return [x + w, round(y + h * t)] - elif side == "left": - return [x, round(y + h * t)] - elif side == "bottom": - return [round(x + w * t), y + h + label_h] - else: - return [round(x + w * t), y] - - -def _fix_bends_inside_nodes(edges, nodes, connections): - """Post-process: fix bends that pass through or graze node icons. - - Checks intermediate points AND segments between them. If a vertical - segment at x=N would pass through a node's x-range and y-range, - shift the bend X to avoid it. - """ - margin = 15 - for ei, e in enumerate(edges): - pts = e["points"] - if len(pts) < 3: - continue - src_id = e.get("from", "") - dst_id = e.get("to", "") - for nid, n in nodes.items(): - if nid == src_id or nid == dst_id: - continue - nx, ny = n["x"], n["y"] - nw = n.get("width", 60) - nh = n.get("height", 60) - # Check intermediate segments (between first and last segments) - for k in range(1, len(pts) - 2): - p1 = pts[k] - p2 = pts[k + 1] - # Vertical segment: same X, check if it passes through node - if abs(p1[0] - p2[0]) < 3: - seg_x = p1[0] - seg_y_lo = min(p1[1], p2[1]) - seg_y_hi = max(p1[1], p2[1]) - if (nx - margin < seg_x < nx + nw + margin and - seg_y_lo < ny + nh + margin and seg_y_hi > ny - margin): - new_x = nx - margin - 5 - # Shift both points of this vertical segment - pts[k] = [new_x, pts[k][1]] - pts[k+1] = [new_x, pts[k+1][1]] - # Also fix the adjacent horizontal segments to stay connected - if k > 0 and abs(pts[k-1][1] - pts[k][1]) < 3: - pts[k-1] = [pts[k-1][0], pts[k][1]] - if k + 2 < len(pts) and abs(pts[k+1][1] - pts[k+2][1]) < 3: - pts[k+2] = [pts[k+2][0], pts[k+1][1]] - break - # Horizontal segment: same Y, check if it passes through node - elif abs(p1[1] - p2[1]) < 3: - seg_y = p1[1] - seg_x_lo = min(p1[0], p2[0]) - seg_x_hi = max(p1[0], p2[0]) - if (ny - margin < seg_y < ny + nh + margin and - seg_x_lo < nx + nw + margin and seg_x_hi > nx - margin): - new_y = ny - margin - 5 - pts[k] = [pts[k][0], new_y] - pts[k+1] = [pts[k+1][0], new_y] - # Fix adjacent vertical segments - if k > 0 and abs(pts[k-1][0] - pts[k][0]) < 3: - pts[k-1] = [pts[k][0], pts[k-1][1]] - if k + 2 < len(pts) and abs(pts[k+1][0] - pts[k+2][0]) < 3: - pts[k+2] = [pts[k+1][0], pts[k+2][1]] - break - - -SNAP_THRESHOLD = 5 -MIN_BEND_MARGIN = 20 -OBSTACLE_MARGIN = 10 - - -def _calc_bend(val, lo, hi, obstacles, axis): - """Calculate bend position avoiding obstacle boundaries and interiors.""" - val = max(val, lo + MIN_BEND_MARGIN) - val = min(val, hi - MIN_BEND_MARGIN) - for obs in obstacles: - if axis == "x": - edge_lo, edge_hi = obs["x"] - OBSTACLE_MARGIN, obs["x"] + obs["width"] + OBSTACLE_MARGIN - else: - edge_lo, edge_hi = obs["y"] - OBSTACLE_MARGIN, obs["y"] + obs["height"] + OBSTACLE_MARGIN - if edge_lo < val < edge_hi: - # Bend is inside obstacle — move to nearest edge outside - dist_to_lo = val - edge_lo - dist_to_hi = edge_hi - val - if dist_to_lo <= dist_to_hi: - val = edge_lo - 5 - else: - val = edge_hi + 5 - val = max(val, lo + MIN_BEND_MARGIN) - val = min(val, hi - MIN_BEND_MARGIN) - return val - - -_DETOUR_MARGIN = 40 - - -def _detour_path(sp, tp, src_side, dst_side, global_bottom): - """Generate a U-shaped detour path for reverse-flow connections. - - Routes below all nodes: src → down → across → up → dst - Always produces a 4-point path (コの字): - [src] → [src_x, bottom] → [dst_x, bottom] → [dst] - """ - sx, sy = sp - tx, ty = tp - bottom_y = global_bottom + _DETOUR_MARGIN - - # Always route: straight down from src, horizontal across bottom, straight up to dst - return [[sx, sy], [sx, bottom_y], [tx, bottom_y], [tx, ty]] - - -def _elbow_path(sp, tp, src_side, dst_side, obstacles=None): - obstacles = obstacles or [] - sx, sy = sp - tx, ty = tp - if src_side in ("left", "right") and dst_side in ("left", "right"): - if abs(sy - ty) <= SNAP_THRESHOLD: - return [[sx, sy], [tx, sy]] - mx = _calc_bend((sx + tx) // 2, min(sx, tx), max(sx, tx), obstacles, "x") - return [[sx, sy], [mx, sy], [mx, ty], [tx, ty]] - if src_side in ("top", "bottom") and dst_side in ("top", "bottom"): - if abs(sx - tx) <= SNAP_THRESHOLD: - return [[sx, sy], [sx, ty]] - my = _calc_bend((sy + ty) // 2, min(sy, ty), max(sy, ty), obstacles, "y") - return [[sx, sy], [sx, my], [tx, my], [tx, ty]] - if src_side in ("left", "right"): - return [[sx, sy], [tx, sy], [tx, ty]] - else: - return [[sx, sy], [sx, ty], [tx, ty]] - - -def box_to_elements(nid, node, is_dark=True): - """Convert box node to shape + textbox elements.""" - box = node["box"] - x, y, w, h = node["x"], node["y"], node["width"], node["height"] - color = box.get("color", "#438DD5") - line_color = box.get("line", color) - - shape = { - "type": "shape", "shape": "rounded_rectangle", - "x": x, "y": y, "width": w, "height": h, - "fill": color, "opacity": 0.18, - "line": line_color, "lineWidth": 1.2, - "adjustments": [0.07], "shadow": "sm", - } - - label_color = "#FFFFFF" if is_dark else "#000000" - sub_color = "#8FA7C4" if is_dark else "#5A6B7D" - desc_color = "#7A8B9C" if is_dark else "#6B7C8D" - - parts = [] - sublabel = box.get("sublabel") - if sublabel: - parts.append("{{" + sub_color + ":" + sublabel + "}}") - label = box.get("title", nid) - parts.append("{{bold," + label_color + ":" + label + "}}") - description = box.get("description") - if description: - parts.append("{{" + desc_color + ":" + description + "}}") - - textbox = { - "type": "textbox", - "x": x, "y": y, "width": w, "height": h, - "align": "center", "valign": "middle", - "fontSize": 11, "text": "\n".join(parts), - } - - return [shape, textbox] +"""Layout engine: compute coordinates from logical structure JSON. + +The engine is split into cohesive submodules; this package re-exports +every name so existing imports (``from sdpm.layout import X`` and the +``from . import X`` used by render/metrics/graph) keep working. +""" + +from .ordering import ( # noqa: F401 + _REFLOW_MAX_LEAVES, + _collect_leaf_ids, + _column_partitions, + _compute_global_peer_positions, + _compute_peer_positions, + _count_crossings_for_mixed_order, + _count_crossings_for_order, + _defect_tuple, + _find_min_crossing_order, + _find_min_crossing_order_mixed, + _find_tile_pools, + _flatten_ids_from_root, + _heuristic_sort_key, + _heuristic_sort_key_mixed, + _is_tile_column, + _optimize_group_order, + _peer_detour_cost, + _promote_branch_nodes, + _reflow_tile_pools, + _tag_manual_branch_anchors, + optimize_order, +) +from .placement import ( # noqa: F401 + _align_branch_lane_anchors, + _align_corresponding_leaves_x, + _align_corresponding_leaves_y, + _align_leaves_to_sibling_centers, + _align_leaves_to_sibling_centers_h, + _cluster_groups_by_axis_overlap, + _collect_horizontal_groups, + _collect_vertical_groups, + _find_leaf_centers_x, + _find_leaf_centers_y, + _get_direct_leaves, + _layout_collect, + _layout_scale, + _layout_translate, + _ranges_overlap, + _recompute_group_bbox, + _reflow_children_along_axis, + cancel_cross_axis_squash, + measure_natural_child_sizes, +) +from .routing import ( # noqa: F401 + _FAN_SPREAD_LIMIT, + _GROUP_BUS_LANE_GAP, + _GROUP_BUS_PORT_GAP, + _PORT_EPS, + _UTURN_CLEAR, + _align_fan_bends, + _align_group_bus, + _count_port_crossings, + _heuristic_port_sort, + _layout_route_connections, + _optimize_port_order, + _recenter_ports, + _safe_separate_bends, + _spread_overlapping_bends, + _straighten_group_edges, + _uturn_group_endpoint_edges, +) +from .geometry import ( # noqa: F401 + _GROUP_FRAME_INSET, + _PIERCE_INSET, + _PIERCE_WEIGHT, + _count_all_crossings, + _count_backwards, + _count_group_pierces, + _count_node_pierces, + _edge_free_bend, + _find_crossing_pairs, + _find_first_crossing, + _is_fan_trunk_t_junction, + _perp_touch, + _port_side, + _seg_crosses_box, + _seg_pierces_node, + _segments_cross, + _segments_intersect, + _segments_overlap_collinear, +) +from .refine import ( # noqa: F401 + _BEND_CANDIDATES, + _BEND_OPT_INSET, + _BEND_OPT_PASSES, + _BEND_OPT_STEP, + _DEFECT_W, + _DETOUR_FACE_MARGIN, + _DETOUR_PASSES, + _FAN_BEND_MARGIN, + _FAN_GROUP_CLEARANCE, + _JOG_ARM_STEP, + _MAX_RESOLVE_ITERATIONS, + _MIN_BEND_SEPARATION, + _PORT_TRIALS, + _RESELECT_CROSS_SLACK, + _SIDE_RESELECT_PASSES, + _W_GROUP_PIERCE_ENGINE, + _align_same_source_bends, + _apply_bend_shift_x, + _apply_bend_shift_y, + _candidate_side_pairs, + _defect_weight, + _detour_around_pierces, + _edge_backwards, + _edge_pierces, + _enclosing_framed_group, + _entry_exit_ok, + _fan_side_vote, + _is_axis_aligned, + _jog_candidates, + _min_bend_separation, + _normalize_path, + _optimize_bends, + _optimize_jog_arm, + _optimize_single_bend, + _path_stability, + _push_trunk_outside_group, + _reselect_sides, + _resolve_crossing_search, + _rewrite_fan, + _separate_close_bends, + _separate_close_horizontal_segments, + _update_bend_x, +) +from .model import ( # noqa: F401 + MIN_BEND_MARGIN, + OBSTACLE_MARGIN, + SNAP_THRESHOLD, + _DETOUR_MARGIN, + _auto_sides, + _calc_bend, + _detour_path, + _elbow_path, + _find_endpoint, + _find_group, + _find_group_for, + _find_node, + _fix_bends_inside_nodes, + _group_member_ids, + _group_qualified_id, + _port_point, + box_to_elements, +) diff --git a/skill/sdpm/layout/geometry.py b/skill/sdpm/layout/geometry.py new file mode 100644 index 00000000..b3d9c1ff --- /dev/null +++ b/skill/sdpm/layout/geometry.py @@ -0,0 +1,532 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +"""Geometric predicates and defect counting: segment intersection, +crossing pairs, node/group pierces, backwards segments, port sides. +""" + +from .model import _find_node, _group_member_ids + + + + +def _perp_touch(v_x, h_y, h_x_min, h_x_max, v_y_min, v_y_max): + """True if a vertical seg (x=v_x, y in [v_y_min,v_y_max]) and a horizontal + seg (y=h_y, x in [h_x_min,h_x_max]) meet — counting T-junctions. + + The meeting point is (v_x, h_y). It must lie within BOTH segments' spans + (endpoints included), and be interior to AT LEAST ONE of them. The latter + excludes only a pure endpoint-to-endpoint touch (two stubs meeting at a + shared corner/port), which is not a visual crossing. A T-junction — where + one segment's endpoint lands in the middle of the other (e.g. an arrow + ending on a line another arrow runs along) — DOES count: the previous + strict-interior test silently dropped these, so two arrows sharing a y and + overlapping in x read as uncrossed when they visibly overlap. + """ + if not (h_x_min <= v_x <= h_x_max and v_y_min <= h_y <= v_y_max): + return False + v_interior = v_y_min < h_y < v_y_max + h_interior = h_x_min < v_x < h_x_max + return v_interior or h_interior + + +def _segments_intersect(a1, a2, b1, b2): + """Test if two axis-aligned segments cross or overlap (for port optimization).""" + ax1, ay1 = a1 + ax2, ay2 = a2 + bx1, by1 = b1 + bx2, by2 = b2 + + a_horiz = ay1 == ay2 + a_vert = ax1 == ax2 + b_horiz = by1 == by2 + b_vert = bx1 == bx2 + + if a_horiz and b_vert: + h_y = ay1 + h_x_min, h_x_max = min(ax1, ax2), max(ax1, ax2) + v_x = bx1 + v_y_min, v_y_max = min(by1, by2), max(by1, by2) + return _perp_touch(v_x, h_y, h_x_min, h_x_max, v_y_min, v_y_max) + if a_vert and b_horiz: + v_x = ax1 + v_y_min, v_y_max = min(ay1, ay2), max(ay1, ay2) + h_y = by1 + h_x_min, h_x_max = min(bx1, bx2), max(bx1, bx2) + return _perp_touch(v_x, h_y, h_x_min, h_x_max, v_y_min, v_y_max) + if a_horiz and b_horiz and ay1 == by1: + a_min, a_max = min(ax1, ax2), max(ax1, ax2) + b_min, b_max = min(bx1, bx2), max(bx1, bx2) + return min(a_max, b_max) - max(a_min, b_min) > 5 + if a_vert and b_vert and ax1 == bx1: + a_min, a_max = min(ay1, ay2), max(ay1, ay2) + b_min, b_max = min(by1, by2), max(by1, by2) + return min(a_max, b_max) - max(a_min, b_min) > 5 + return False + + +def _find_first_crossing(edges): + """Find the first pair of crossing segments across all edges.""" + for i in range(len(edges)): + pts_i = edges[i]["points"] + if len(pts_i) < 2 or edges[i].get("_fanout"): + continue + for j in range(i + 1, len(edges)): + pts_j = edges[j]["points"] + if len(pts_j) < 2 or edges[j].get("_fanout"): + continue + for si in range(len(pts_i) - 1): + for sj in range(len(pts_j) - 1): + if _segments_intersect(pts_i[si], pts_i[si + 1], pts_j[sj], pts_j[sj + 1]): + return (i, si, j, sj) + return None + + +def _segments_overlap_collinear(a1, a2, b1, b2): + """True if the two axis-aligned segments lie on the same line and overlap + (as opposed to crossing perpendicularly).""" + if a1[1] == a2[1] and b1[1] == b2[1] and a1[1] == b1[1]: # both horizontal, same Y + a_min, a_max = min(a1[0], a2[0]), max(a1[0], a2[0]) + b_min, b_max = min(b1[0], b2[0]), max(b1[0], b2[0]) + return min(a_max, b_max) - max(a_min, b_min) > 5 + if a1[0] == a2[0] and b1[0] == b2[0] and a1[0] == b1[0]: # both vertical, same X + a_min, a_max = min(a1[1], a2[1]), max(a1[1], a2[1]) + b_min, b_max = min(b1[1], b2[1]), max(b1[1], b2[1]) + return min(a_max, b_max) - max(a_min, b_min) > 5 + return False + + +def _segments_cross(a1, a2, b1, b2): + """Test if two axis-aligned line segments (a1-a2) and (b1-b2) cross or overlap. + + Detects: + 1. Perpendicular crossings (one horizontal, one vertical) + 2. Collinear overlap (parallel segments sharing the same axis with overlapping range) + + Used by the builder's conservative edge-crossing warning. Distinct from + ``_segments_intersect`` (which counts T-junctions via ``_perp_touch``): this + one uses strict interior ``<`` on both segments so a shared endpoint does not + read as a crossing. + """ + ax1, ay1 = a1 + ax2, ay2 = a2 + bx1, by1 = b1 + bx2, by2 = b2 + + a_horiz = ay1 == ay2 + a_vert = ax1 == ax2 + b_horiz = by1 == by2 + b_vert = bx1 == bx2 + + # Perpendicular crossings + if a_horiz and b_vert: + h_y = ay1 + h_x_min, h_x_max = min(ax1, ax2), max(ax1, ax2) + v_x = bx1 + v_y_min, v_y_max = min(by1, by2), max(by1, by2) + return h_x_min < v_x < h_x_max and v_y_min < h_y < v_y_max + if a_vert and b_horiz: + v_x = ax1 + v_y_min, v_y_max = min(ay1, ay2), max(ay1, ay2) + h_y = by1 + h_x_min, h_x_max = min(bx1, bx2), max(bx1, bx2) + return h_x_min < v_x < h_x_max and v_y_min < h_y < v_y_max + + # Collinear overlap: both horizontal on same Y + if a_horiz and b_horiz and ay1 == by1: + a_min, a_max = min(ax1, ax2), max(ax1, ax2) + b_min, b_max = min(bx1, bx2), max(bx1, bx2) + overlap = min(a_max, b_max) - max(a_min, b_min) + return overlap > 5 + + # Collinear overlap: both vertical on same X + if a_vert and b_vert and ax1 == bx1: + a_min, a_max = min(ay1, ay2), max(ay1, ay2) + b_min, b_max = min(by1, by2), max(by1, by2) + overlap = min(a_max, b_max) - max(a_min, b_min) + return overlap > 5 + + return False + + +def _find_crossing_pairs(edges): + """Return the set of edge-index pairs (i, j) that genuinely cross. + + This is the single source of truth for "do two edges cross"; both the QA + metric (:func:`_count_all_crossings`, which just takes ``len``) and the + builder's human-readable warning consume it, so the two can never disagree. + + Two edges that SHARE an endpoint node (a fan-out from the same source or a + fan-in to the same target) are allowed to run on top of each other on their + shared trunk — that overlap IS the merged bundle, not a crossing. So for + such pairs we ignore collinear overlaps and only count a genuine + perpendicular crossing. Unrelated edges still count overlaps (two separate + arrows drawn on the same line read as a defect). + + Shared-endpoint pairs also produce a perpendicular T-junction where each + spoke peels off the shared trunk at its own port — the meeting point sits at + the spoke's true endpoint (pts[0] / pts[-1]). That T is the bundle's + intended structure, not a crossing, so it is skipped. A meeting that is + interior to BOTH polylines (a genuine 4-way X, e.g. two spokes crossing + mid-span) is always counted, even for a shared-endpoint pair.""" + # Pre-compute each edge's bounding box once; two edges whose boxes don't + # overlap can't cross, so we skip the O(segments²) inner test entirely. + # This is the hot path (called thousands of times by the bend/side/detour + # optimizers), so the cheap box reject saves the bulk of the work. + boxes = [] + for e in edges: + pts = e["points"] + if len(pts) < 2: + boxes.append(None) + continue + xs = [p[0] for p in pts] + ys = [p[1] for p in pts] + boxes.append((min(xs), min(ys), max(xs), max(ys))) + + pairs = set() + for i in range(len(edges)): + pts_i = edges[i]["points"] + if len(pts_i) < 2: + continue + ei = edges[i] + bi = boxes[i] + for j in range(i + 1, len(edges)): + pts_j = edges[j]["points"] + if len(pts_j) < 2: + continue + bj = boxes[j] + # Bounding-box reject: no overlap → no crossing. + if bi[0] > bj[2] or bj[0] > bi[2] or bi[1] > bj[3] or bj[1] > bi[3]: + continue + ej = edges[j] + shares_endpoint = ( + ei.get("from") == ej.get("from") + or ei.get("to") == ej.get("to") + or ei.get("from") == ej.get("to") + or ei.get("to") == ej.get("from") + ) + found = False + for si in range(len(pts_i) - 1): + if found: + break + for sj in range(len(pts_j) - 1): + if _segments_intersect(pts_i[si], pts_i[si + 1], pts_j[sj], pts_j[sj + 1]): + if shares_endpoint: + # A shared-endpoint bundle's collinear overlap is the + # intended trunk, not a crossing. + if _segments_overlap_collinear( + pts_i[si], pts_i[si + 1], pts_j[sj], pts_j[sj + 1] + ): + continue + # Two edges of the SAME fan bundle (same shared trunk) + # meet where each spoke peels off that trunk — a + # structural T-junction, not a crossing. Skip it, but + # only for the trunk-peel T: a genuine interior× + # interior X (two spokes truly crossing mid-span) is + # still counted. + if _is_fan_trunk_t_junction( + ei, ej, pts_i, si, pts_j, sj + ): + continue + pairs.add((i, j)) + found = True + break + return pairs + + +def _count_all_crossings(edges): + """Count crossing SEGMENT-pairs across all edges. + + NOTE: this counts every crossing segment-pair, so two edges that cross at + several segments contribute more than one. That is deliberate — the whole + order/reflow/bend search was tuned against this magnitude, so it must stay a + segment count, NOT a distinct-edge-pair count. For the human-readable "which + edges cross" warning use :func:`_find_crossing_pairs` (distinct edge pairs); + both share the same per-segment skip rules so they never disagree on + *whether* a pair crosses, only on how the total is tallied.""" + boxes = [] + for e in edges: + pts = e["points"] + if len(pts) < 2: + boxes.append(None) + continue + xs = [p[0] for p in pts] + ys = [p[1] for p in pts] + boxes.append((min(xs), min(ys), max(xs), max(ys))) + + count = 0 + for i in range(len(edges)): + pts_i = edges[i]["points"] + if len(pts_i) < 2: + continue + ei = edges[i] + bi = boxes[i] + for j in range(i + 1, len(edges)): + pts_j = edges[j]["points"] + if len(pts_j) < 2: + continue + bj = boxes[j] + if bi[0] > bj[2] or bj[0] > bi[2] or bi[1] > bj[3] or bj[1] > bi[3]: + continue + ej = edges[j] + shares_endpoint = ( + ei.get("from") == ej.get("from") + or ei.get("to") == ej.get("to") + or ei.get("from") == ej.get("to") + or ei.get("to") == ej.get("from") + ) + for si in range(len(pts_i) - 1): + for sj in range(len(pts_j) - 1): + if _segments_intersect(pts_i[si], pts_i[si + 1], pts_j[sj], pts_j[sj + 1]): + if shares_endpoint: + if _segments_overlap_collinear( + pts_i[si], pts_i[si + 1], pts_j[sj], pts_j[sj + 1] + ): + continue + if _is_fan_trunk_t_junction( + ei, ej, pts_i, si, pts_j, sj + ): + continue + count += 1 + return count + + +def _is_fan_trunk_t_junction(ei, ej, pts_i, si, pts_j, sj): + """True if two same-bundle fan edges meet at the shared trunk as a peel-off + T (structural), as opposed to a genuine 4-way crossing. + + Both edges must be `_fan_locked` onto the SAME bundle (same mode, axis, + trunk coordinate, and shared port). In that bundle the trunk is the line at + ``trunk`` on the bundle's axis; each spoke leaves the trunk perpendicular. + Their segments meet on the trunk line. That meeting is the intended shape, + UNLESS the meeting point is strictly interior to BOTH segments (two spokes + crossing away from the trunk), which is a real defect and returns False. + """ + la, lb = ei.get("_fan_locked"), ej.get("_fan_locked") + if not la or not lb: + return False + if (la["mode"] != lb["mode"] or la["axis"] != lb["axis"] + or la["trunk"] != lb["trunk"] or la["port"] != lb["port"]): + return False # different bundles → treat as unrelated, count normally + a1, a2 = pts_i[si], pts_i[si + 1] + b1, b2 = pts_j[sj], pts_j[sj + 1] + a_h, a_v = a1[1] == a2[1], a1[0] == a2[0] + b_h, b_v = b1[1] == b2[1], b1[0] == b2[0] + if a_h and b_v: + mx, my = b1[0], a1[1] + elif a_v and b_h: + mx, my = a1[0], b1[1] + else: + return False + # The meeting must lie on the bundle's trunk line; otherwise it is two + # spokes meeting away from the trunk (count it). + trunk = la["trunk"] + on_trunk = (mx == trunk) if la["axis"] == "x" else (my == trunk) + if not on_trunk: + return False + # A real 4-way X (interior to both segments) is a defect even on the trunk; + # only an endpoint-on-trunk peel-off is structural. + a_lo_x, a_hi_x = min(a1[0], a2[0]), max(a1[0], a2[0]) + a_lo_y, a_hi_y = min(a1[1], a2[1]), max(a1[1], a2[1]) + b_lo_x, b_hi_x = min(b1[0], b2[0]), max(b1[0], b2[0]) + b_lo_y, b_hi_y = min(b1[1], b2[1]), max(b1[1], b2[1]) + interior_a = a_lo_x < mx < a_hi_x or a_lo_y < my < a_hi_y + interior_b = b_lo_x < mx < b_hi_x or b_lo_y < my < b_hi_y + return not (interior_a and interior_b) + + +# Negative inset = a keep-out margin AROUND each icon. A line running along or +# just outside an icon's edge reads visually as touching/piercing it, so we +# count it as a pierce and push it away. Kept small so legitimate adjacent +# perpendicular stubs are not over-constrained. +_PIERCE_INSET = -9 +_PIERCE_WEIGHT = 4 + + +def _seg_pierces_node(p1, p2, n): + """True if axis-aligned segment p1-p2 passes through (or grazes) node n. + + A negative _PIERCE_INSET expands the test rectangle beyond the icon so + segments running flush against an edge are flagged, matching what reads + visually as touching the icon. + """ + rx, ry = n["x"], n["y"] + rw, rh = n.get("width", 60), n.get("height", n.get("width", 60)) + x0, y0 = rx + _PIERCE_INSET, ry + _PIERCE_INSET + x1, y1 = rx + rw - _PIERCE_INSET, ry + rh - _PIERCE_INSET + ax, ay = p1 + bx, by = p2 + if ax == bx: # vertical + return x0 < ax < x1 and min(ay, by) < y1 and max(ay, by) > y0 + if ay == by: # horizontal + return y0 < ay < y1 and min(ax, bx) < x1 and max(ax, bx) > x0 + return False + + +def _count_node_pierces(edges, nodes): + """Count (edge, node) pairs where an edge passes through a non-endpoint icon.""" + # Pre-compute each node's short id and its expanded pierce box ONCE (this is + # a hot path called thousands of times by the optimizers). The old code + # recomputed nid.rsplit and the box for every (edge, node) pair — millions + # of times on a dense diagram. + node_info = [] + for nid, n in nodes.items(): + short = nid.rsplit(".", 1)[-1] + rx, ry = n["x"], n["y"] + rw = n.get("width", 60) + rh = n.get("height", rw) + x0, y0 = rx + _PIERCE_INSET, ry + _PIERCE_INSET + x1, y1 = rx + rw - _PIERCE_INSET, ry + rh - _PIERCE_INSET + node_info.append((nid, short, n, x0, y0, x1, y1)) + + count = 0 + for e in edges: + pts = e["points"] + if len(pts) < 2: + continue + ignore = {e["from"], e["to"]} + # Edge bounding box for a cheap reject against each node's pierce box. + exs = [p[0] for p in pts] + eys = [p[1] for p in pts] + emnx, emny, emxx, emxy = min(exs), min(eys), max(exs), max(eys) + for nid, short, n, x0, y0, x1, y1 in node_info: + if nid in ignore or short in ignore: + continue + # Box reject: edge bbox vs node's expanded pierce box. + if emnx > x1 or x0 > emxx or emny > y1 or y0 > emxy: + continue + for k in range(len(pts) - 1): + if _seg_pierces_node(pts[k], pts[k + 1], n): + count += 1 + break + return count + + +_GROUP_FRAME_INSET = 2 + + +def _seg_crosses_box(p1, p2, bx, by, bw, bh, inset=0): + """True if axis-aligned segment p1-p2 passes through rectangle (bx,by,bw,bh). + + `inset` shrinks the rectangle so a segment merely running along the frame + edge (or a port landing exactly on it) is not counted as crossing through. + """ + x0, y0 = bx + inset, by + inset + x1, y1 = bx + bw - inset, by + bh - inset + ax, ay = p1 + bx2, by2 = p2 + if ax == bx2: # vertical segment + return x0 < ax < x1 and min(ay, by2) < y1 and max(ay, by2) > y0 + if ay == by2: # horizontal segment + return y0 < ay < y1 and min(ax, bx2) < x1 and max(ax, bx2) > x0 + return False + + +def _count_group_pierces(edges, groups, nodes): + """Count (edge, framed-group) pairs where an edge cuts through a group's + drawn frame without connecting to that group or any icon inside it. + + Only groups with a visible frame (``groupType``) are considered — an + invisible grouping has no box to violate. An edge is exempt for a group if + it starts/ends at that group OR at any of its member icons (those edges are + SUPPOSED to enter the box). Everything else slicing through the frame reads + as a stray line crossing an unrelated container, which looks broken. + """ + if not groups: + return 0 + framed = [(gid, g) for gid, g in groups.items() if g.get("groupType")] + if not framed: + return 0 + count = 0 + for e in edges: + pts = e["points"] + if len(pts) < 2: + continue + efrom = e["from"].rsplit(".", 1)[-1] + eto = e["to"].rsplit(".", 1)[-1] + for gid, g in framed: + gshort = gid.rsplit(".", 1)[-1] + if efrom == gshort or eto == gshort: + continue # edge connects to the group box itself + members = _group_member_ids(nodes, groups, gid) + if efrom in members or eto in members: + continue # edge connects to an icon inside this group + if any(_seg_crosses_box(pts[k], pts[k + 1], g["x"], g["y"], + g["width"], g["height"], _GROUP_FRAME_INSET) + for k in range(len(pts) - 1)): + count += 1 + return count + + +def _count_backwards(edges, nodes): + """Count edges whose first/last segment heads opposite to its port normal. + + A "backwards" segment leaves (or enters) an icon edge pointing back across + the icon — e.g. a bottom port whose first move is upward. The port side is + inferred from the endpoint's position on the node so this works regardless + of label offset (a bottom port sits below the icon's x-span). + """ + count = 0 + for e in edges: + pts = e["points"] + if len(pts) < 2: + continue + src = _find_node(nodes, e["from"]) + dst = _find_node(nodes, e["to"]) + for node, p_port, p_next, leaving in ( + (src, pts[0], pts[1], True), + (dst, pts[-1], pts[-2], False), + ): + if node is None: + continue + side = _port_side(node, p_port) + if side is None: + continue + # Outward normal for the port; the adjacent point must lie on the + # outward side (for a source) — i.e. not back across the icon. + if side == "right" and p_next[0] < p_port[0] - 2: + count += 1 + elif side == "left" and p_next[0] > p_port[0] + 2: + count += 1 + elif side == "bottom" and p_next[1] < p_port[1] - 2: + count += 1 + elif side == "top" and p_next[1] > p_port[1] + 2: + count += 1 + return count + + +def _port_side(node, pt): + """Infer which icon edge a port point sits on (label-offset aware).""" + x, y = pt + cx, cy = node["x"], node["y"] + w = node.get("width", 60) + h = node.get("height", w) + if cx - 2 <= x <= cx + w + 2: + if y >= cy + h - 2: + return "bottom" + if y <= cy + 2: + return "top" + if cy - 2 <= y <= cy + h + 2: + if x <= cx + 2: + return "left" + if x >= cx + w - 2: + return "right" + return None + + +def _edge_free_bend(pts): + """Return ('x'|'y', lo, hi) for the movable middle bend of a 4-point elbow. + + A VHV/HVH path's two middle points share one coordinate (the trunk + position) that can slide between the two endpoints without moving the + port-anchored endpoints or creating diagonals. Returns None for paths + that have no such free bend (straight lines, detours, fan-outs). + """ + if len(pts) != 4: + return None + # HVH: horiz, vert, horiz → middle two points share X (vertical trunk) + if pts[0][1] == pts[1][1] and pts[1][0] == pts[2][0] and pts[2][1] == pts[3][1]: + return ("x", pts[0][0], pts[3][0]) + # VHV: vert, horiz, vert → middle two points share Y (horizontal trunk) + if pts[0][0] == pts[1][0] and pts[1][1] == pts[2][1] and pts[2][0] == pts[3][0]: + return ("y", pts[0][1], pts[3][1]) + return None diff --git a/skill/sdpm/layout/model.py b/skill/sdpm/layout/model.py new file mode 100644 index 00000000..a10c5292 --- /dev/null +++ b/skill/sdpm/layout/model.py @@ -0,0 +1,284 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +"""Diagram model helpers: node/group lookup, port geometry, elbow paths +and box-node element generation. +""" + + + +def _find_group_for(node_id, node_group): + """Find parent group id for a node, handling qualified ids.""" + if node_id in node_group: + return node_group[node_id] + for nid, gid in node_group.items(): + if nid.endswith("." + node_id): + return gid + return None + + +def _find_node(nodes, node_id): + if node_id in nodes: + return nodes[node_id] + for nid, n in nodes.items(): + if nid.endswith("." + node_id): + return n + return None + + +def _find_group(groups, gid): + """Resolve a group by id (qualified or short), if groups is provided.""" + if not groups: + return None + if gid in groups: + return groups[gid] + for g_id, g in groups.items(): + if g_id.endswith("." + gid): + return g + return None + + +def _find_endpoint(nodes, groups, eid): + """Resolve a connection endpoint that may be a node OR a group. + + Returns (geom, is_group): geom is a dict with x/y/width/height (both nodes + and laid-out groups carry these), is_group flags a group target so callers + can treat the box edge as the port and skip the group's own children as + obstacles. A node takes precedence over a group with the same id. + """ + n = _find_node(nodes, eid) + if n is not None: + return n, False + g = _find_group(groups, eid) + if g is not None: + return g, True + return None, False + + +def _group_qualified_id(groups, gid): + """Return the fully-qualified key of group gid in the flat groups dict.""" + if not groups: + return None + if gid in groups: + return gid + for g_id in groups: + if g_id.endswith("." + gid): + return g_id + return None + + +def _group_member_ids(nodes, groups, gid): + """Short ids of all leaf nodes inside group gid (for obstacle exclusion). + + The collected `groups` dict stores children as qualified id strings, and + every leaf node inside the group is a key in `nodes` prefixed by the + group's qualified id. We match on that prefix. + """ + qid = _group_qualified_id(groups, gid) + if not qid: + return set() + prefix = qid + "." + out = set() + for nid in nodes: + if nid == qid or nid.startswith(prefix): + out.add(nid.rsplit(".", 1)[-1]) + return out + + +def _auto_sides(src, dst, group_direction=None): + if group_direction == "horizontal": + sx = src["x"] + src["width"] // 2 + dx = dst["x"] + dst["width"] // 2 + return ("right", "left") if dx > sx else ("left", "right") + if group_direction == "vertical": + sy = src["y"] + src["height"] // 2 + dy = dst["y"] + dst["height"] // 2 + return ("bottom", "top") if dy > sy else ("top", "bottom") + sx = src["x"] + src["width"] // 2 + sy = src["y"] + src["height"] // 2 + dx = dst["x"] + dst["width"] // 2 + dy = dst["y"] + dst["height"] // 2 + diffx, diffy = dx - sx, dy - sy + # Prefer vertical when dx and dy are close (within 30% ratio) + # This produces more natural top-down flow in diagrams + if abs(diffy) > 0 and abs(diffx) / abs(diffy) < 1.3: + return ("bottom", "top") if diffy > 0 else ("top", "bottom") + if abs(diffx) >= abs(diffy): + return ("right", "left") if diffx > 0 else ("left", "right") + else: + return ("bottom", "top") if diffy > 0 else ("top", "bottom") + + +def _port_point(node, side, index, count, label_h): + x, y, w, h = node["x"], node["y"], node["width"], node["height"] + t = 0.5 if count <= 1 else (index + 1) / (count + 1) + if side == "right": + return [x + w, round(y + h * t)] + elif side == "left": + return [x, round(y + h * t)] + elif side == "bottom": + return [round(x + w * t), y + h + label_h] + else: + return [round(x + w * t), y] + + +def _fix_bends_inside_nodes(edges, nodes, connections): + """Post-process: fix bends that pass through or graze node icons. + + Checks intermediate points AND segments between them. If a vertical + segment at x=N would pass through a node's x-range and y-range, + shift the bend X to avoid it. + """ + margin = 15 + for ei, e in enumerate(edges): + pts = e["points"] + if len(pts) < 3: + continue + src_id = e.get("from", "") + dst_id = e.get("to", "") + for nid, n in nodes.items(): + if nid == src_id or nid == dst_id: + continue + nx, ny = n["x"], n["y"] + nw = n.get("width", 60) + nh = n.get("height", 60) + # Check intermediate segments (between first and last segments) + for k in range(1, len(pts) - 2): + p1 = pts[k] + p2 = pts[k + 1] + # Vertical segment: same X, check if it passes through node + if abs(p1[0] - p2[0]) < 3: + seg_x = p1[0] + seg_y_lo = min(p1[1], p2[1]) + seg_y_hi = max(p1[1], p2[1]) + if (nx - margin < seg_x < nx + nw + margin and + seg_y_lo < ny + nh + margin and seg_y_hi > ny - margin): + new_x = nx - margin - 5 + # Shift both points of this vertical segment + pts[k] = [new_x, pts[k][1]] + pts[k+1] = [new_x, pts[k+1][1]] + # Also fix the adjacent horizontal segments to stay connected + if k > 0 and abs(pts[k-1][1] - pts[k][1]) < 3: + pts[k-1] = [pts[k-1][0], pts[k][1]] + if k + 2 < len(pts) and abs(pts[k+1][1] - pts[k+2][1]) < 3: + pts[k+2] = [pts[k+2][0], pts[k+1][1]] + break + # Horizontal segment: same Y, check if it passes through node + elif abs(p1[1] - p2[1]) < 3: + seg_y = p1[1] + seg_x_lo = min(p1[0], p2[0]) + seg_x_hi = max(p1[0], p2[0]) + if (ny - margin < seg_y < ny + nh + margin and + seg_x_lo < nx + nw + margin and seg_x_hi > nx - margin): + new_y = ny - margin - 5 + pts[k] = [pts[k][0], new_y] + pts[k+1] = [pts[k+1][0], new_y] + # Fix adjacent vertical segments + if k > 0 and abs(pts[k-1][0] - pts[k][0]) < 3: + pts[k-1] = [pts[k][0], pts[k-1][1]] + if k + 2 < len(pts) and abs(pts[k+1][0] - pts[k+2][0]) < 3: + pts[k+2] = [pts[k+1][0], pts[k+2][1]] + break + + +SNAP_THRESHOLD = 5 +MIN_BEND_MARGIN = 20 +OBSTACLE_MARGIN = 10 + + +def _calc_bend(val, lo, hi, obstacles, axis): + """Calculate bend position avoiding obstacle boundaries and interiors.""" + val = max(val, lo + MIN_BEND_MARGIN) + val = min(val, hi - MIN_BEND_MARGIN) + for obs in obstacles: + if axis == "x": + edge_lo, edge_hi = obs["x"] - OBSTACLE_MARGIN, obs["x"] + obs["width"] + OBSTACLE_MARGIN + else: + edge_lo, edge_hi = obs["y"] - OBSTACLE_MARGIN, obs["y"] + obs["height"] + OBSTACLE_MARGIN + if edge_lo < val < edge_hi: + # Bend is inside obstacle — move to nearest edge outside + dist_to_lo = val - edge_lo + dist_to_hi = edge_hi - val + if dist_to_lo <= dist_to_hi: + val = edge_lo - 5 + else: + val = edge_hi + 5 + val = max(val, lo + MIN_BEND_MARGIN) + val = min(val, hi - MIN_BEND_MARGIN) + return val + + +_DETOUR_MARGIN = 40 + + +def _detour_path(sp, tp, src_side, dst_side, global_bottom): + """Generate a U-shaped detour path for reverse-flow connections. + + Routes below all nodes: src → down → across → up → dst + Always produces a 4-point path (コの字): + [src] → [src_x, bottom] → [dst_x, bottom] → [dst] + """ + sx, sy = sp + tx, ty = tp + bottom_y = global_bottom + _DETOUR_MARGIN + + # Always route: straight down from src, horizontal across bottom, straight up to dst + return [[sx, sy], [sx, bottom_y], [tx, bottom_y], [tx, ty]] + + +def _elbow_path(sp, tp, src_side, dst_side, obstacles=None): + obstacles = obstacles or [] + sx, sy = sp + tx, ty = tp + if src_side in ("left", "right") and dst_side in ("left", "right"): + if abs(sy - ty) <= SNAP_THRESHOLD: + return [[sx, sy], [tx, sy]] + mx = _calc_bend((sx + tx) // 2, min(sx, tx), max(sx, tx), obstacles, "x") + return [[sx, sy], [mx, sy], [mx, ty], [tx, ty]] + if src_side in ("top", "bottom") and dst_side in ("top", "bottom"): + if abs(sx - tx) <= SNAP_THRESHOLD: + return [[sx, sy], [sx, ty]] + my = _calc_bend((sy + ty) // 2, min(sy, ty), max(sy, ty), obstacles, "y") + return [[sx, sy], [sx, my], [tx, my], [tx, ty]] + if src_side in ("left", "right"): + return [[sx, sy], [tx, sy], [tx, ty]] + else: + return [[sx, sy], [sx, ty], [tx, ty]] + + +def box_to_elements(nid, node, is_dark=True): + """Convert box node to shape + textbox elements.""" + box = node["box"] + x, y, w, h = node["x"], node["y"], node["width"], node["height"] + color = box.get("color", "#438DD5") + line_color = box.get("line", color) + + shape = { + "type": "shape", "shape": "rounded_rectangle", + "x": x, "y": y, "width": w, "height": h, + "fill": color, "opacity": 0.18, + "line": line_color, "lineWidth": 1.2, + "adjustments": [0.07], "shadow": "sm", + } + + label_color = "#FFFFFF" if is_dark else "#000000" + sub_color = "#8FA7C4" if is_dark else "#5A6B7D" + desc_color = "#7A8B9C" if is_dark else "#6B7C8D" + + parts = [] + sublabel = box.get("sublabel") + if sublabel: + parts.append("{{" + sub_color + ":" + sublabel + "}}") + label = box.get("title", nid) + parts.append("{{bold," + label_color + ":" + label + "}}") + description = box.get("description") + if description: + parts.append("{{" + desc_color + ":" + description + "}}") + + textbox = { + "type": "textbox", + "x": x, "y": y, "width": w, "height": h, + "align": "center", "valign": "middle", + "fontSize": 11, "text": "\n".join(parts), + } + + return [shape, textbox] diff --git a/skill/sdpm/layout/ordering.py b/skill/sdpm/layout/ordering.py new file mode 100644 index 00000000..cbe0ec51 --- /dev/null +++ b/skill/sdpm/layout/ordering.py @@ -0,0 +1,812 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +"""Node-order optimization: crossing-minimizing child order within groups, +branch-node promotion, and tile-pool reflow. +""" + + + +def optimize_order(tree, enable_reflow=True): + """Pre-process: reorder children in groups to minimize edge crossings. + + Handles both leaf-only AND mixed groups (groups containing sub-groups). + Uses brute-force permutation search for small groups (≤7 children) and + heuristic sorting for larger ones. Counts actual crossing pairs using a + two-layer position model (internal positions + external peer positions). + + ``enable_reflow`` runs the tile-pool reflow pass at the end. It is set + False when the reflow pass itself re-lays-out candidate arrangements, so + the real-routing evaluation does not recurse back into reflow. + + Mutates tree in-place. Call before _layout_scale. + """ + connections = tree.get("connections", []) + if not connections: + return + # Shape-first pre-pass: pull degree-1 auxiliary nodes that sit ON the main + # flow line out into a perpendicular lane so the straight through-edge does + # not pierce them (e.g. a Bedrock fallback wedged between Router and the + # model group). Runs before ordering so the new sub-groups get ordered too. + _promote_branch_nodes(tree, connections) + # Also tag hand-authored invisible lanes (e.g. a {router, bedrock} vertical + # column the LLM wrote directly) with their flow anchor, so the same + # anchor-on-flow alignment applies as for engine-promoted lanes. + _tag_manual_branch_anchors(tree, connections) + # Order children within each group to minimize crossings. Uses a routed- + # quality model that accounts not just for leaf-vs-leaf crossings but also + # for an edge to a sibling GROUP box having to detour around a nearer child + # (see _count_crossings_for_mixed_order's peer-adjacency term). + _optimize_group_order(tree, connections) + # Reflow tile pools: a group whose children are all anonymous, frameless, + # leaf-only sub-columns of one orientation (pure tiling with no semantic + # sub-grouping) can have its leaves reassigned across columns to shorten + # wiring — seating externally-connected leaves at the peer-facing end. + # Ordering alone can't do this (it never moves a leaf between sub-columns). + if enable_reflow: + _reflow_tile_pools(tree) + + +def _tag_manual_branch_anchors(node, connections, root=None): + """Tag hand-authored invisible linear sub-groups with their flow anchor. + + The engine's own _promote_branch_nodes tags the lanes IT creates, but an + author may hand-write the same shape — an invisible (no groupType/label) + horizontal/vertical group stacking a flow node with an auxiliary one, e.g. + ``{router, bedrock}`` so Bedrock sits beside Router. Block-placement then + centres that group by its bounding box, pushing the flow node off the main + line. We detect such a group and tag it with ``_branch_anchor`` = the sole + member that connects OUTSIDE the group (the flow node); the layout pass then + keeps that member on the flow line and lets the rest hang off to the side. + Mutates in place. Skips groups that already carry the tag. + """ + if root is None: + root = node + for child in node.get("children", []): + _tag_manual_branch_anchors(child, connections, root) + + children = node.get("children", []) + if len(children) < 2 or node.get("_branch_anchor"): + return + # Only invisible linear groups qualify (a visible box is a deliberate + # cluster, not a flow node + offset branch). + if node.get("direction") not in ("horizontal", "vertical"): + return + if node.get("groupType") or node.get("label"): + return + # Every direct child must be a bare leaf (the {anchor, branch} pattern). + member_ids = [] + for c in children: + if c.get("children") or "id" not in c: + return + member_ids.append(c["id"]) + member_set = set(member_ids) + + # An "anchor" is a member that connects to something OUTSIDE this group. + outward = [] + for c in children: + cid = c["id"] + for conn in connections: + other = None + if conn["from"] == cid: + other = conn["to"] + elif conn["to"] == cid: + other = conn["from"] + if other is not None and other not in member_set: + outward.append(cid) + break + # Tag only when exactly ONE member reaches outside — that is unambiguously + # the flow node; the rest are branches hanging off it. + if len(outward) == 1: + node["_branch_anchor"] = outward[0] + + +def _promote_branch_nodes(node, connections, root=None): + """Move degree-1 'branch' leaves off the main flow into a perpendicular lane. + + Detects the human-layout pattern: in a linear (horizontal/vertical) group, + a leaf ``b`` with exactly one connection whose anchor ``a`` is a sibling in + the same group, where another edge from ``a`` runs straight past ``b``'s + slot to a node on the far side. Drawn linearly, that through-edge pierces + ``b``. The fix (Shape-First / bend-minimisation philosophy: keep the main + line straight, displace the auxiliary node) wraps ``{a, b}`` into an + invisible sub-group oriented PERPENDICULAR to the parent, so ``a`` stays on + the flow line and ``b`` is offset to the side. Mutates ``node`` in place. + """ + if root is None: + root = node + children = node.get("children", []) + # Depth-first so inner groups are handled before we look at this level. + for child in children: + _promote_branch_nodes(child, connections, root) + children = node.get("children", []) + direction = node.get("direction") + if len(children) < 3 or direction not in ("horizontal", "vertical"): + return + + # Map each direct child -> the leaf ids it contains; and each leaf -> the + # index of the direct child that owns it (a sub-group counts as one slot). + leaf_to_idx = {} + for i, c in enumerate(children): + ids = [] + _collect_leaf_ids(c, ids) + for lid in ids: + leaf_to_idx[lid] = i + + # Global degree + neighbour list over all connections. + deg = {} + nbr = {} + for conn in connections: + for a, b in ((conn["from"], conn["to"]), (conn["to"], conn["from"])): + deg[a] = deg.get(a, 0) + 1 + nbr.setdefault(a, []).append(b) + + # Collect qualifying branch leaves, grouped by their anchor. + branches_by_anchor = {} + for i, c in enumerate(children): + if c.get("children"): + continue # only bare leaves can be branch nodes + bid = c.get("id") + if bid is None or deg.get(bid, 0) != 1: + continue + aid = nbr[bid][0] + ia = leaf_to_idx.get(aid) + if ia is None or children[ia].get("children"): + continue # anchor must be a direct-child leaf of this same group + # Is there a through-edge from the anchor that crosses b's slot? + ib = i + through = False + for other in nbr.get(aid, []): + if other == bid: + continue + ic = leaf_to_idx.get(other) + if ic is not None and (ia - ib) * (ic - ib) < 0: + through = True + break + if through: + branches_by_anchor.setdefault(aid, (ia, []))[1].append((ib, c)) + + if not branches_by_anchor: + return + + perp = "vertical" if direction == "horizontal" else "horizontal" + remove = set() + inserts = {} # insertion index -> new sub-group node + for aid, (ia, brs) in branches_by_anchor.items(): + anchor = children[ia] + slot = min([ia] + [ib for ib, _ in brs]) + # anchor first so it keeps the centred slot on the flow line; branches + # follow in their original order. + members = [anchor] + [c for _, c in sorted(brs)] + inserts[slot] = { + "id": "_branchlane_" + (aid or "x"), + "direction": perp, + "children": members, + # Remember which member is the flow anchor so the layout pass can + # keep IT (not the lane's centroid) on the main flow line, letting + # the branch hang off to the side. + "_branch_anchor": aid, + } + remove.add(ia) + remove.update(ib for ib, _ in brs) + + rebuilt = [] + for i, c in enumerate(children): + if i in inserts: + rebuilt.append(inserts[i]) + if i in remove: + continue + rebuilt.append(c) + node["children"] = rebuilt + + +def _optimize_group_order(node, connections, root=None): + """Recursively optimize child order within groups (leaf-only AND mixed).""" + if root is None: + root = node + children = node.get("children", []) + if not children: + return + + # Recurse depth-first so inner groups are optimized before outer ones + for child in children: + _optimize_group_order(child, connections, root) + + if len(children) < 2: + return + + # Collect ALL leaf ids reachable from each child (for mixed groups) + child_leaf_ids = {} + for c in children: + ids = [] + _collect_leaf_ids(c, ids) + child_leaf_ids[c["id"]] = ids + + # All leaf ids in this group (union of children's leaves) + all_leaf_ids = set() + for ids in child_leaf_ids.values(): + all_leaf_ids.update(ids) + + if not all_leaf_ids: + return + + # Collect connections relevant to any leaf in this group + relevant = [] + for conn in connections: + src, dst = conn["from"], conn["to"] + if src in all_leaf_ids or dst in all_leaf_ids: + relevant.append(conn) + + if not relevant: + return + + # Identify internal order constraints: + # If a connection goes from a leaf in child A to a leaf in child B, + # child A must come before child B. + leaf_to_child_id = {} + for cid, leaf_ids in child_leaf_ids.items(): + for lid in leaf_ids: + leaf_to_child_id[lid] = cid + + internal_order_constraints = [] + for conn in connections: + src, dst = conn["from"], conn["to"] + if src in leaf_to_child_id and dst in leaf_to_child_id: + src_child = leaf_to_child_id[src] + dst_child = leaf_to_child_id[dst] + if src_child != dst_child: + internal_order_constraints.append((src_child, dst_child)) + + # For brute-force: try all permutations if ≤7 children + if len(children) <= 7: + best_order = _find_min_crossing_order_mixed( + children, relevant, connections, internal_order_constraints, + child_leaf_ids, root + ) + if best_order is not None: + node["children"] = best_order + return + + # Fallback heuristic for larger groups: sort by connected peer position + flat_order = [] + _flatten_ids_from_root(root, flat_order) + id_position = {nid: i for i, nid in enumerate(flat_order)} + node["children"] = sorted(children, key=lambda c: _heuristic_sort_key_mixed(c, connections, id_position, child_leaf_ids)) + + +# Max leaves in a tile pool we will exhaustively reflow (n! candidate arrangements +# each re-routed; 6 → 720 is the practical ceiling for the local pass). +_REFLOW_MAX_LEAVES = 6 + + +def _is_tile_column(node): + """A tile is an anonymous, frameless, leaf-only sub-group (pure spacing, + no semantic meaning): no groupType, no label, no branch-anchor tag, and all + of its own children are leaves.""" + kids = node.get("children") + if not kids: + return False + if node.get("groupType") or node.get("label") or node.get("_branch_anchor"): + return False + return all(not k.get("children") for k in kids) + + +def _find_tile_pools(node, out): + """Collect groups that are pure tile pools. + + A tile pool is a group whose children are ALL anonymous frameless leaf-only + sub-columns (tiles) of the SAME orientation, with ≥2 tiles. Such a group is + a grid with no semantic sub-grouping, so its leaves are interchangeable + across tiles — safe to reassign to shorten wiring. + """ + kids = node.get("children", []) + if kids: + tiles = [k for k in kids if _is_tile_column(k)] + if len(tiles) >= 2 and len(tiles) == len(kids): + dirs = {t.get("direction") for t in tiles} + total = sum(len(t["children"]) for t in tiles) + if len(dirs) == 1 and 2 <= total <= _REFLOW_MAX_LEAVES: + out.append(node) + for k in kids: + _find_tile_pools(k, out) + + +def _defect_tuple(tree, width, height): + """Optimize child order (WITHOUT reflow), route the tree, and return the + lexicographic quality key used to compare tile arrangements: hard defects + first, then wire length as the soft tie-break. + + The intra-column order of each candidate is decided by the normal order + optimizer (reflow disabled so it can't recurse), so the reflow search only + has to explore how leaves are *partitioned* across columns — not their order + within a column.""" + import copy + from .render import build_layout + from .metrics import measure_layout + t = copy.deepcopy(tree) + optimize_order(t, enable_reflow=False) + nodes, groups, edges, rb, _ch, _cv = build_layout( + t, None, None, width, height, optimize=False) + m = measure_layout(nodes, groups, edges, rb, width, height) + return (round(m["overflow"], 3), m["crossings"], m["pierces"], + m["group_pierces"], m["backwards"], m["wire_norm"]) + + +def _column_partitions(leaf_ids, col_sizes): + """Yield every way to partition ``leaf_ids`` into ordered columns of the + given sizes, as a tuple of frozensets (membership only — intra-column order + is decided later by the order optimizer). Deduplicates equal-size columns so + (A|B) and (B|A) are not both tried.""" + from itertools import combinations + + def rec(remaining, sizes): + if not sizes: + yield () + return + size = sizes[0] + for combo in combinations(sorted(remaining), size): + rest = remaining - set(combo) + for tail in rec(rest, sizes[1:]): + yield (frozenset(combo),) + tail + + seen = set() + for parts in rec(set(leaf_ids), col_sizes): + # Canonicalize columns of equal size to dedupe symmetric partitions. + key = tuple(sorted(parts, key=lambda s: sorted(s))) + if key in seen: + continue + seen.add(key) + yield parts + + +def _reflow_tile_pools(tree, width=1720, height=800): + """Repartition leaves across the columns of each tile pool to shorten wiring. + + A tile pool is a group whose children are all anonymous, frameless, leaf-only + sub-columns of one orientation — pure tiling with no semantic sub-grouping. + Ordering alone never moves a leaf between sub-columns, so an author's column + split (e.g. Orders+Payments in one column, Catalog+Cart in the other) is + frozen even when regrouping would shorten wiring. + + For each pool we enumerate the ways to split its leaves across the columns + (membership only — each candidate's intra-column order is then set by the + normal order optimizer), re-route each with the REAL engine, and keep the + best. A candidate is kept only if it does not worsen any hard defect + (overflow/crossings/pierces/group_pierces/backwards) versus the author's + arrangement; wire length breaks ties. Because hard defects rank ahead of + wire, reflow can never trade a crossing for shorter wire — it is a pure + quality-preserving cleanup. + """ + pools = [] + _find_tile_pools(tree, pools) + if not pools: + return + + for pool in pools: + tiles = pool["children"] + col_sizes = [len(t["children"]) for t in tiles] + leaves = [leaf for t in tiles for leaf in t["children"]] + by_id = {leaf["id"]: leaf for leaf in leaves} + leaf_ids = [leaf["id"] for leaf in leaves] + + def apply_partition(parts): + """Fill each tile column with the members of the corresponding + partition set (intra-column order is refined later by the optimizer, + so any stable order is fine here).""" + for ci, members in enumerate(parts): + tiles[ci]["children"] = [by_id[i] for i in leaf_ids if i in members] + + # Baseline: the author's arrangement, order-optimized. + author_parts = tuple( + frozenset(leaf["id"] for leaf in tile["children"]) for tile in tiles) + best_parts = author_parts + best_key = _defect_tuple(tree, width, height) + + for parts in _column_partitions(leaf_ids, col_sizes): + if parts == author_parts: + continue + apply_partition(parts) + key = _defect_tuple(tree, width, height) + if key < best_key: + best_key = key + best_parts = parts + + apply_partition(best_parts) + # Let the order optimizer set the final intra-column order for the chosen + # partition (reflow disabled to avoid recursing into this pass). + optimize_order(tree, enable_reflow=False) + + +def _collect_leaf_ids(node, out): + """Collect all leaf node ids reachable from a node.""" + children = node.get("children", []) + if not children: + if "id" in node: + out.append(node["id"]) + return + for child in children: + _collect_leaf_ids(child, out) + + +def _find_min_crossing_order_mixed(children, relevant, all_connections, internal_order_constraints, child_leaf_ids, root): + """Try all permutations of children (mixed groups) and return the one with fewest crossings. + + For mixed groups, each child may be a leaf OR a sub-group containing multiple leaves. + The crossing count uses ALL leaves within each child's subtree. + """ + from itertools import permutations + + best_key = None + best_perm = None + + # Two position maps from the full tree: + # - leaf-only, for the crossing count (unchanged legacy behaviour — adding + # group ids here would reclassify group-endpoint edges and shuffle orders + # on unrelated diagrams like omnichannel). + # - group-inclusive, for the detour tie-break ONLY, so a many-to-one edge + # to a sibling GROUP box is visible when seating its single connected + # child (the DR diagram's API → Data tier). + leaf_positions = _compute_global_peer_positions(children, all_connections, child_leaf_ids, root) + group_positions = _compute_global_peer_positions(children, all_connections, child_leaf_ids, root, include_groups=True) + + for perm in permutations(children): + perm_ids = [c["id"] for c in perm] + + # Skip permutations that violate internal order constraints + if internal_order_constraints: + valid = True + for src, dst in internal_order_constraints: + if src in perm_ids and dst in perm_ids: + if perm_ids.index(src) > perm_ids.index(dst): + valid = False + break + if not valid: + continue + + crossings = _count_crossings_for_mixed_order(perm, relevant, leaf_positions, child_leaf_ids) + # Tie-break: among equal-crossing orders prefer the one where each child + # that connects to an OUTSIDE peer sits at the end of the row facing that + # peer. Otherwise a lone edge to a sibling group (e.g. an API container → + # the Data tier) leaves author order and detours around the outer + # sibling. This is a pure secondary key — it can never pick an order with + # more crossings — so it can't regress a diagram that ordering already + # solved; it only breaks ties the crossing count leaves open. + detour = _peer_detour_cost(perm, relevant, group_positions, child_leaf_ids) + key = (crossings, detour) + if best_key is None or key < best_key: + best_key = key + best_perm = list(perm) + if crossings == 0 and detour == 0: + break + + return best_perm + + +def _peer_detour_cost(perm, relevant, global_positions, child_leaf_ids): + """Secondary ordering key: how far each externally-connected child sits from + the row end facing its outside peer. + + For every edge between an internal leaf and an external peer (leaf OR group + box), the internal endpoint ideally sits at the row end nearest that peer — + the right end if the peer is to the right (higher global position than this + row's own centre), the left end if to the left. The cost sums the slot + distance from that ideal end; 0 when every connected child already hugs the + correct end. The datum is THIS row's mean peer-space position (not a global + average), so left/right is judged relative to the row itself — the fix for + the earlier version that flipped sides between otherwise-identical rows. + """ + n = len(perm) + # Slot of each internal leaf under this permutation, and per-child leaf sets. + leaf_slot = {} + internal = set() + child_leaf_sets = [] + for slot, child in enumerate(perm): + cl = set(child_leaf_ids.get(child["id"], [child["id"]])) + child_leaf_sets.append(cl) + for lid in cl: + leaf_slot[lid] = slot + internal.add(lid) + + # Only apply this tie-break when EXACTLY ONE direct child connects outside + # the group. That is the unambiguous "seat the one connected child at the + # peer-facing end" case (the DR diagram's API container). When several + # children connect outside, where each should sit is a multi-way trade the + # crossing model already handles; forcing one toward a peer end there just + # shuffles the row and can push another edge through a frame (the + # omnichannel services regression). Return 0 = no tie-break preference. + connected_children = 0 + for cl in child_leaf_sets: + if any((cn["from"] in cl and cn["to"] not in internal) + or (cn["to"] in cl and cn["from"] not in internal) + for cn in relevant): + connected_children += 1 + if connected_children != 1: + return 0 + + # This row's own centre in global peer-space: mean global position of the + # external peers it connects to (so "left/right" is relative to the row). + peer_positions = [] + for conn in relevant: + for a, b in ((conn["from"], conn["to"]), (conn["to"], conn["from"])): + if a in internal and b in global_positions and b not in internal: + peer_positions.append(global_positions[b]) + if not peer_positions: + return 0 + datum = sum(peer_positions) / len(peer_positions) + cost = 0 + for conn in relevant: + for a, b in ((conn["from"], conn["to"]), (conn["to"], conn["from"])): + if a in leaf_slot and b in global_positions and b not in internal: + ideal = (n - 1) if global_positions[b] >= datum else 0 + cost += abs(leaf_slot[a] - ideal) + return cost + + +def _compute_global_peer_positions(children, all_connections, child_leaf_ids, root, include_groups=False): + """Compute normalized positions for external peers. + + External peers are nodes NOT contained in any of the children being permuted. + Their position is based on DFS order of leaf nodes in the root tree, + normalized to a [0, N] range where N is the number of internal leaf slots. + + ``include_groups`` also registers GROUP ids at the mean position of their + member leaves. This is used ONLY by the detour tie-break (so a many-to-one + edge to a sibling group box is visible when seating the single connected + child). The crossing count deliberately uses the leaf-only map — adding + groups there reclassifies group-endpoint edges and shuffles unrelated + diagrams' orders. + """ + # All leaves within this group + all_internal = set() + for ids in child_leaf_ids.values(): + all_internal.update(ids) + + # Get global DFS order of ALL leaf nodes only + global_leaves = [] + _collect_leaf_ids(root, global_leaves) + + # Only external leaves get positions + external_leaves = [lid for lid in global_leaves if lid not in all_internal] + if not external_leaves: + return {} + + # Assign sequential positions to external leaves + pos = {lid: i for i, lid in enumerate(external_leaves)} + + if include_groups: + # Position external GROUP ids at the mean position of their members so a + # connection targeting a group BOX (e.g. an API container → the Data + # tier) is visible to the detour tie-break. + def _register(node): + ml = [] + _collect_leaf_ids(node, ml) + gid = node.get("id") + if gid is not None and node.get("children"): + ext = [pos[m] for m in ml if m in pos] + if ext and not any(m in all_internal for m in ml): + pos[gid] = sum(ext) / len(ext) + for ch in node.get("children", []): + _register(ch) + _register(root) + return pos + + +def _count_crossings_for_mixed_order(perm, relevant, global_positions, child_leaf_ids): + """Count edge crossings for a specific permutation of children in a mixed group. + + Two edges cross if their internal endpoints are in one order but their + external endpoints are in the opposite order. For edges where BOTH endpoints + are internal, they cross if one child's position inverts relative to another. + + Uses a two-layer approach: + - Internal positions: assigned based on permutation order (sequential ints) + - External positions: from global_positions (sequential ints, different namespace) + + For crossing detection, only edges sharing the same "side" (both internal-to-external + or both internal-to-internal) can cross each other. + """ + # Assign positions to all internal leaves based on the permutation order + internal_positions = {} + pos_counter = 0 + for child in perm: + cid = child["id"] + leaves = child_leaf_ids.get(cid, []) + if not leaves: + internal_positions[cid] = pos_counter + pos_counter += 1 + else: + for lid in leaves: + internal_positions[lid] = pos_counter + pos_counter += 1 + + # Categorize edges: + # Type A: internal→external (src is internal, dst is external) + # Type B: external→internal (src is external, dst is internal) + # Type C: internal→internal (both endpoints internal) + edges_a = [] # (internal_pos, external_pos) + edges_b = [] # (external_pos, internal_pos) + edges_c = [] # (internal_pos_src, internal_pos_dst) + + for conn in relevant: + src, dst = conn["from"], conn["to"] + src_int = internal_positions.get(src) + dst_int = internal_positions.get(dst) + src_ext = global_positions.get(src) + dst_ext = global_positions.get(dst) + + if src_int is not None and dst_int is not None: + edges_c.append((src_int, dst_int)) + elif src_int is not None and dst_ext is not None: + edges_a.append((src_int, dst_ext)) + elif src_ext is not None and dst_int is not None: + edges_b.append((src_ext, dst_int)) + + # Count crossings within each category + crossings = 0 + + # Type A crossings: two edges from internal to external cross if + # internal order and external order are inverted + for i in range(len(edges_a)): + for j in range(i + 1, len(edges_a)): + a1, b1 = edges_a[i] + a2, b2 = edges_a[j] + if (a1 - a2) * (b1 - b2) < 0: + crossings += 1 + + # Type B crossings: two edges from external to internal cross if + # external order and internal order are inverted + for i in range(len(edges_b)): + for j in range(i + 1, len(edges_b)): + a1, b1 = edges_b[i] + a2, b2 = edges_b[j] + if (a1 - a2) * (b1 - b2) < 0: + crossings += 1 + + # Type C crossings: internal-to-internal edges + for i in range(len(edges_c)): + for j in range(i + 1, len(edges_c)): + a1, b1 = edges_c[i] + a2, b2 = edges_c[j] + if (a1 - a2) * (b1 - b2) < 0: + crossings += 1 + + return crossings + + +def _heuristic_sort_key_mixed(child, connections, id_position, child_leaf_ids): + """Heuristic sort key for a child (leaf or sub-group) in a mixed group.""" + cid = child["id"] + leaf_ids = child_leaf_ids.get(cid, [cid]) + + weights = [] + for lid in leaf_ids: + for conn in connections: + if conn["from"] == lid and conn["to"] in id_position: + weights.append(id_position[conn["to"]]) + if conn["to"] == lid and conn["from"] in id_position: + weights.append(id_position[conn["from"]]) + if weights: + return sum(weights) / len(weights) + return id_position.get(cid, 0) + + +def _find_min_crossing_order(children, relevant, all_connections, internal_order_constraints=None): + """Try all permutations and return the one with fewest crossings.""" + from itertools import permutations + + best_crossings = None + best_perm = None + + # Determine fixed external peer positions from the tree structure + peer_positions = _compute_peer_positions(children, all_connections) + + for perm in permutations(children): + perm_ids = [c["id"] for c in perm] + + # Skip permutations that violate internal order constraints + if internal_order_constraints: + valid = True + for src, dst in internal_order_constraints: + if src in perm_ids and dst in perm_ids: + if perm_ids.index(src) > perm_ids.index(dst): + valid = False + break + if not valid: + continue + + crossings = _count_crossings_for_order(perm_ids, relevant, peer_positions) + if best_crossings is None or crossings < best_crossings: + best_crossings = crossings + best_perm = list(perm) + if crossings == 0: + break + + return best_perm + + +def _compute_peer_positions(children, all_connections): + """Compute fixed positions for external peers (nodes outside this group). + + External peers are assigned positions based on their relative order among + each other — determined by their index in the sibling group they belong to. + This position is independent of the permutation being tested. + """ + leaf_ids = set(c["id"] for c in children) + # Collect all external peers connected to this group + peers = set() + for conn in all_connections: + if conn["from"] in leaf_ids and conn["to"] not in leaf_ids: + peers.add(conn["to"]) + if conn["to"] in leaf_ids and conn["from"] not in leaf_ids: + peers.add(conn["from"]) + + # Group external peers by which group-internal nodes they connect to. + # Peers connecting to the same set of internal nodes should have the same position. + # Peers are ordered by their first appearance in connections list. + peer_order = [] + seen = set() + for conn in all_connections: + for p in [conn["from"], conn["to"]]: + if p in peers and p not in seen: + peer_order.append(p) + seen.add(p) + + # Assign a simple sequential position based on order of appearance + return {p: i for i, p in enumerate(peer_order)} + + +def _count_crossings_for_order(ordered_ids, relevant, peer_positions): + """Count edge crossings given a specific ordering of nodes in a group. + + Uses fixed peer_positions for external nodes and the permutation positions + for internal nodes. Two edges cross if their endpoint orders are inverted. + """ + pos = {nid: i for i, nid in enumerate(ordered_ids)} + id_set = set(ordered_ids) + + edges = [] + for conn in relevant: + src, dst = conn["from"], conn["to"] + if src in id_set and dst in id_set: + edges.append((pos[src], pos[dst])) + elif src in id_set: + ext_pos = peer_positions.get(dst, len(ordered_ids) / 2) + edges.append((pos[src], ext_pos)) + elif dst in id_set: + ext_pos = peer_positions.get(src, len(ordered_ids) / 2) + edges.append((ext_pos, pos[dst])) + + crossings = 0 + for i in range(len(edges)): + for j in range(i + 1, len(edges)): + a1, b1 = edges[i] + a2, b2 = edges[j] + if (a1 - a2) * (b1 - b2) < 0: + crossings += 1 + return crossings + + +def _flatten_ids_from_root(node, out): + """Collect all node ids in DFS order from a subtree root.""" + if "id" in node: + out.append(node["id"]) + for child in node.get("children", []): + _flatten_ids_from_root(child, out) + + +def _heuristic_sort_key(child_id, connections, id_position): + """Fallback heuristic: sort by average position of connected source nodes.""" + src_positions = [] + for conn in connections: + if conn["to"] == child_id and conn["from"] in id_position: + src_positions.append(id_position[conn["from"]]) + if src_positions: + return sum(src_positions) / len(src_positions) + weights = [] + for conn in connections: + if conn["from"] == child_id and conn["to"] in id_position: + weights.append(id_position[conn["to"]]) + if conn["to"] == child_id and conn["from"] in id_position: + weights.append(id_position[conn["from"]]) + if weights: + return sum(weights) / len(weights) + return id_position.get(child_id, 0) diff --git a/skill/sdpm/layout/placement.py b/skill/sdpm/layout/placement.py new file mode 100644 index 00000000..b86307d6 --- /dev/null +++ b/skill/sdpm/layout/placement.py @@ -0,0 +1,610 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +"""Placement pipeline: scale, translate, align and collect the node/group +geometry computed from the logical structure tree. +""" + + + +def _layout_scale(node, parent_dir="horizontal", parent_align="center", spacing_scale_h=1.0, spacing_scale_v=1.0): + """Recursive layout engine. Calculates bindings (x, y, width, height) for each node bottom-up.""" + children = node.get("children", []) + direction = node.get("direction", parent_dir) + align = node.get("align", parent_align) + is_group = len(children) > 0 + icon_size = node.get("iconSize", 60) + + # A per-node spacing scale lets the fit pass compress ONE overflowing group + # (and its descendants) without touching sibling groups that already fit. + # Multiplies into the inherited factor so nested overrides compose. + spacing_scale_h *= node.get("_hscale", 1.0) + spacing_scale_v *= node.get("_vscale", 1.0) + + def sh(v): + return max(10, round(v * spacing_scale_h)) + def sv(v): + return max(10, round(v * spacing_scale_v)) + + if is_group: + has_visual_group = node.get("groupType") or node.get("label") + if has_visual_group: + margin = node.get("margin", {"top": sv(20), "right": sh(20), "bottom": sv(20), "left": sh(20)}) + padding = node.get("padding", {"top": sv(70), "right": sh(30), "bottom": sv(30), "left": sh(30)}) + else: + margin = node.get("margin", {"top": sv(5), "right": sh(5), "bottom": sv(5), "left": sh(5)}) + padding = node.get("padding", {"top": sv(5), "right": sh(5), "bottom": sv(5), "left": sh(5)}) + else: + box = node.get("box") + if box: + bw = box.get("width", 240) + if "height" in box: + bh = box["height"] + else: + char_per_line = max(1, bw // 10) + lines = 0 + for field in [box.get("sublabel"), box.get("title", node.get("id", "")), box.get("description")]: + if field: + for paragraph in str(field).split("\n"): + lines += max(1, -(-len(paragraph) // char_per_line)) + bh = lines * 24 + 40 + margin = node.get("margin", {"top": sv(20), "right": sh(20), "bottom": sv(20), "left": sh(20)}) + padding = {"top": 0, "right": 0, "bottom": 0, "left": 0} + node["_bindings"] = [0, 0, bw, bh] + node["_margin"] = margin + node["_padding"] = padding + return + label_h = sv(35) + raw_label = node.get("label", "") + label_lines = raw_label.replace("\\n", "\n").split("\n") + label_w = max((len(line) * 8 for line in label_lines), default=0) + label_h = sv(35) + (len(label_lines) - 1) * sv(18) + half_label_overhang = max(0, (label_w - icon_size) // 2) + margin = node.get("margin", {"top": sv(20), "right": max(sh(20), half_label_overhang + 5), "bottom": label_h + sv(10), "left": max(sh(20), half_label_overhang + 5)}) + padding = {"top": 0, "right": 0, "bottom": 0, "left": 0} + node["_bindings"] = [0, 0, icon_size, icon_size] + node["_margin"] = margin + node["_padding"] = padding + return + + for child in children: + _layout_scale(child, direction, align, spacing_scale_h, spacing_scale_v) + + reverse = node.get("reverse", False) + ordered = list(reversed(children)) if reverse else children + for i, child in enumerate(ordered): + cb = child["_bindings"] + cm = child["_margin"] + if i == 0: + dx = cm["left"] - cb[0] + dy = cm["top"] - cb[1] + _layout_translate(child, dx, dy) + else: + prev = ordered[i - 1] + pb = prev["_bindings"] + pm = prev["_margin"] + cb = child["_bindings"] + if direction == "horizontal": + nx = pb[0] + pb[2] + pm["right"] + cm["left"] + if align == "top": + ny = ordered[0]["_bindings"][1] + elif align == "bottom": + ny = pb[0 + 1] + pb[3] - cb[3] + else: + ny = pb[1] + (pb[3] - cb[3]) // 2 + _layout_translate(child, nx - cb[0], ny - cb[1]) + else: + ny = pb[1] + pb[3] + pm["bottom"] + cm["top"] + if align == "left": + nx = ordered[0]["_bindings"][0] + elif align == "right": + nx = pb[0] + pb[2] - cb[2] + else: + nx = pb[0] + (pb[2] - cb[2]) // 2 + _layout_translate(child, nx - cb[0], ny - cb[1]) + + # Post-process 1: align corresponding leaves across sibling vertical groups + # so that e.g. Lambda(row1) in group A has the same Y as DynamoDB(row1) in group B. + if direction == "horizontal": + _align_corresponding_leaves_y(ordered) + elif direction == "vertical": + _align_corresponding_leaves_x(ordered) + + # Post-process 2: align leaf nodes to the median leaf center of sibling groups. + # This ensures single icons sit at the visual center of adjacent vertical groups + # rather than at the center of the group's bounding box (which includes padding). + if align == "center" and direction == "horizontal": + _align_leaves_to_sibling_centers(ordered) + elif align == "center" and direction == "vertical": + _align_leaves_to_sibling_centers_h(ordered) + + # The alignment passes above translate LEAVES (including ones nested inside + # child sub-groups) without touching the sub-group's own box. Re-derive each + # child group's bbox bottom-up so its frame still wraps its (now-moved) + # icons — otherwise the box stays at the pre-alignment position and the + # shifted icons spill outside the frame (e.g. a Data Tier whose ElastiCache + # dropped below the solid border). + for c in children: + if c.get("children"): + _recompute_group_bbox(c) + + min_x = min(c["_bindings"][0] - c["_margin"]["left"] for c in children) + min_y = min(c["_bindings"][1] - c["_margin"]["top"] for c in children) + max_x = max(c["_bindings"][0] + c["_bindings"][2] + c["_margin"]["right"] for c in children) + max_y = max(c["_bindings"][1] + c["_bindings"][3] + c["_margin"]["bottom"] for c in children) + + gx = min_x - padding["left"] + gy = min_y - padding["top"] + gw = (max_x - min_x) + padding["left"] + padding["right"] + gh = (max_y - min_y) + padding["top"] + padding["bottom"] + + node["_bindings"] = [gx, gy, gw, gh] + node["_margin"] = margin + node["_padding"] = padding + + +def cancel_cross_axis_squash(tree, natural_sizes, cum_h, cum_v, target_w, target_h): + """Undo the global cross-axis squash on sibling groups that already fit. + + The global fit applies ONE scale per axis. On the CROSS axis (height for a + horizontal root, width for a vertical one) sibling groups don't sum — the + total is just the largest. So when one tall group (e.g. a 4-team agent tower) + forces the whole slide to squash vertically, short siblings (Entry, + Orchestration) get dragged down with it and turn unreadable. + + The cross axis can't be made to fit by scaling alone if the tall group is + genuinely too big (its icons, not just gaps, fill the height) — the layout + WILL overflow, and that's acceptable; the overflow warning tells the author + to restructure the tall group. What we refuse to accept is crushing the + groups that DID fit. So for each top-level group whose NATURAL cross-axis + size already fit the target, we cancel the global cross-axis squash by + giving it a compensating `_vscale`/`_hscale` ( = 1/cum ), restoring its + natural size; the oversized group keeps the squash. Mutates `tree`; returns + True if any compensation was assigned (caller re-runs `_layout_scale`). + """ + direction = tree.get("direction", "horizontal") + src_children = tree.get("children", tree.get("nodes", [])) + changed = False + # Only meaningful when the cross axis was actually compressed (<1). + if direction == "horizontal" and target_h and cum_v and cum_v < 0.97: + for src in src_children: + if not src.get("children"): + continue + if natural_sizes.get(id(src), (0, 0))[1] <= target_h: + src["_vscale"] = src.get("_vscale", 1.0) * (1.0 / cum_v) + changed = True + elif direction == "vertical" and target_w and cum_h and cum_h < 0.97: + for src in src_children: + if not src.get("children"): + continue + if natural_sizes.get(id(src), (0, 0))[0] <= target_w: + src["_hscale"] = src.get("_hscale", 1.0) * (1.0 / cum_h) + changed = True + return changed + + +def measure_natural_child_sizes(tree, root): + """Map each top-level source child -> its natural (w, h) before global fit. + + Keyed by id() of the source-child dict so it survives the deepcopy/rebuild + cycle as long as the caller passes the SAME tree dicts. Used by + cancel_cross_axis_squash to tell which groups fit on their own. + """ + out = {} + src_children = tree.get("children", tree.get("nodes", [])) + laid = root.get("children", []) + if len(laid) != len(src_children): + return out + for src, node in zip(src_children, laid): + b = node["_bindings"] + out[id(src)] = (b[2], b[3]) + return out + + +def _recompute_group_bbox(node): + """Re-derive a group's bbox from its children, bottom-up, in place. + + Used after the leaf-alignment passes move icons that live inside nested + sub-groups: those moves don't update the sub-group's own `_bindings`, so its + frame would otherwise stay where it was before the shift and no longer wrap + its icons. Recurses so deep nesting is corrected from the leaves up. Reuses + the group's stored `_padding` so the frame keeps its label band and margins. + + A grown sub-group can also start OVERLAPPING a sibling that was placed + against its old (smaller) bounds — e.g. a Data Tier that stretched to match + a tall sibling column now collides with the Observability group stacked + below it. After re-deriving child boxes we re-flow this node's children + along its own axis to restore the margin gaps, then derive this node's box. + """ + children = node.get("children") + if not children: + return + for c in children: + if c.get("children"): + _recompute_group_bbox(c) + _reflow_children_along_axis(node, children) + padding = node.get("_padding", {"top": 0, "right": 0, "bottom": 0, "left": 0}) + min_x = min(c["_bindings"][0] - c["_margin"]["left"] for c in children) + min_y = min(c["_bindings"][1] - c["_margin"]["top"] for c in children) + max_x = max(c["_bindings"][0] + c["_bindings"][2] + c["_margin"]["right"] for c in children) + max_y = max(c["_bindings"][1] + c["_bindings"][3] + c["_margin"]["bottom"] for c in children) + node["_bindings"] = [ + min_x - padding["left"], + min_y - padding["top"], + (max_x - min_x) + padding["left"] + padding["right"], + (max_y - min_y) + padding["top"] + padding["bottom"], + ] + + +def _reflow_children_along_axis(node, children): + """Push apart consecutive children that overlap on the group's main axis. + + Only moves along the layout axis (vertical group → shift Y, horizontal → + shift X) and only ever forward (never pulls a child back), so the cross-axis + alignment the leaf passes just established is preserved. A no-op when the + children already clear each other — the common case — so it cannot disturb + a layout that didn't grow. + """ + direction = node.get("direction", "horizontal") + if len(children) < 2: + return + reverse = node.get("reverse", False) + ordered = list(reversed(children)) if reverse else children + for i in range(1, len(ordered)): + prev = ordered[i - 1]["_bindings"] + pm = ordered[i - 1]["_margin"] + cur = ordered[i]["_bindings"] + cm = ordered[i]["_margin"] + if direction == "vertical": + need_top = prev[1] + prev[3] + pm["bottom"] + cm["top"] + delta = need_top - cur[1] + if delta > 0: + _layout_translate(ordered[i], 0, delta) + else: + need_left = prev[0] + prev[2] + pm["right"] + cm["left"] + delta = need_left - cur[0] + if delta > 0: + _layout_translate(ordered[i], delta, 0) + + +def _ranges_overlap(lo1, hi1, lo2, hi2): + """True if the 1-D intervals [lo1,hi1] and [lo2,hi2] overlap.""" + return lo1 < hi2 and lo2 < hi1 + + +def _cluster_groups_by_axis_overlap(groups_with_leaves, axis): + """Partition same-count groups into clusters that genuinely share a row + (axis="x", i.e. their X spans overlap → stacked vertically) or a column + (axis="y", i.e. their Y spans overlap → placed side by side). + + Leaf alignment only makes sense WITHIN such a cluster. Aligning the Nth + leaf across groups that are laid out along the same axis we're aligning + (e.g. forcing the 1st leaf of four side-by-side horizontal groups to one X) + collapses them onto each other — the bug this guards against. + """ + # bindings: [x, y, w, h]. axis "x" → position x(0), size w(2); + # axis "y" → position y(1), size h(3). + pos_idx = 0 if axis == "x" else 1 + size_idx = 2 if axis == "x" else 3 + items = [] + for group, leaves in groups_with_leaves: + b = group["_bindings"] + lo = b[pos_idx] + hi = b[pos_idx] + b[size_idx] + items.append((lo, hi, group, leaves)) + items.sort(key=lambda it: it[0]) + + clusters = [] + for lo, hi, group, leaves in items: + placed = False + for cluster in clusters: + # cluster shares the span if it overlaps ANY member + if any(_ranges_overlap(lo, hi, c_lo, c_hi) for c_lo, c_hi, _, _ in cluster): + cluster.append((lo, hi, group, leaves)) + placed = True + break + if not placed: + clusters.append([(lo, hi, group, leaves)]) + return [[(g, lv) for _, _, g, lv in cluster] for cluster in clusters] + + +def _align_corresponding_leaves_y(ordered): + """Align Y of corresponding leaves across vertical groups that sit SIDE BY + SIDE (their Y spans overlap). Groups stacked vertically must NOT be aligned + to each other — that would collapse them onto one row. + + Collects all vertical groups (at any depth) with the same leaf count, then + aligns Nth leaves to the same Y center only within each side-by-side cluster. + """ + vertical_groups = [] + for child in ordered: + _collect_vertical_groups(child, vertical_groups) + + if len(vertical_groups) < 2: + return + + by_count = {} + for group, leaves in vertical_groups: + n = len(leaves) + by_count.setdefault(n, []).append((group, leaves)) + + for groups_with_same_count in by_count.values(): + if len(groups_with_same_count) < 2: + continue + # Only align groups whose Y spans overlap (truly side by side). + for cluster in _cluster_groups_by_axis_overlap(groups_with_same_count, "y"): + if len(cluster) < 2: + continue + leaf_count = len(cluster[0][1]) + for row_idx in range(leaf_count): + row_leaves = [leaves[row_idx] for _, leaves in cluster] + centers = [leaf["_bindings"][1] + leaf["_bindings"][3] // 2 for leaf in row_leaves] + target_cy = max(centers) + for leaf in row_leaves: + b = leaf["_bindings"] + current_cy = b[1] + b[3] // 2 + dy = target_cy - current_cy + if dy != 0: + _layout_translate(leaf, 0, dy) + + +def _align_corresponding_leaves_x(ordered): + """Align X of corresponding leaves across horizontal groups that are STACKED + VERTICALLY (their X spans overlap). Groups placed side by side must NOT be + aligned to each other — that would collapse them onto one column. + """ + horizontal_groups = [] + for child in ordered: + _collect_horizontal_groups(child, horizontal_groups) + + if len(horizontal_groups) < 2: + return + + by_count = {} + for group, leaves in horizontal_groups: + n = len(leaves) + by_count.setdefault(n, []).append((group, leaves)) + + for groups_with_same_count in by_count.values(): + if len(groups_with_same_count) < 2: + continue + # Only align groups whose X spans overlap (truly stacked vertically). + for cluster in _cluster_groups_by_axis_overlap(groups_with_same_count, "x"): + if len(cluster) < 2: + continue + leaf_count = len(cluster[0][1]) + for col_idx in range(leaf_count): + col_leaves = [leaves[col_idx] for _, leaves in cluster] + centers = [leaf["_bindings"][0] + leaf["_bindings"][2] // 2 for leaf in col_leaves] + target_cx = max(centers) + for leaf in col_leaves: + b = leaf["_bindings"] + current_cx = b[0] + b[2] // 2 + dx = target_cx - current_cx + if dx != 0: + _layout_translate(leaf, dx, 0) + + +def _collect_vertical_groups(node, out): + """Collect the OUTERMOST vertical groups with their direct leaves. + + Descends through horizontal groups (their vertical children may be genuine + side-by-side peers) but STOPS at a vertical group: a vertical sub-group + nested inside another vertical column is that column's internal structure + (e.g. an {Inference, Bedrock} branch deep inside a Processing column), NOT a + peer of the column's top-level siblings. Collecting it would let row + alignment drag an unrelated top-level group down to match a deeply-nested + one — the bug that bent group-to-group arrows into L shapes. + """ + if not node.get("children"): + return + if node.get("direction", "horizontal") == "vertical": + leaves = [c for c in node["children"] if not c.get("children")] + # Only a CLEAN column (every direct child is a bare leaf) can be + # row-aligned: row N must mean the same thing in every column. A column + # that also holds a sub-group (e.g. Processing = three Lambdas + an + # {Inference, Bedrock} branch) has its leaves bunched at the top while a + # peer column spreads them over its full height — aligning row-by-row + # then drags the mixed column's box center off the flow line. Skip it. + if leaves and len(leaves) == len(node["children"]): + out.append((node, leaves)) + return # internals of a vertical column are not peers of its siblings + for child in node.get("children", []): + _collect_vertical_groups(child, out) + + +def _collect_horizontal_groups(node, out): + """Collect the OUTERMOST horizontal groups with their direct leaves. + + Mirror of _collect_vertical_groups: descends through vertical groups but + stops at a horizontal group, so a horizontal sub-row nested inside another + horizontal row is not column-aligned with that row's top-level siblings. + """ + if not node.get("children"): + return + if node.get("direction", "horizontal") == "horizontal": + leaves = [c for c in node["children"] if not c.get("children")] + # Only a CLEAN row (every direct child is a bare leaf) can be + # column-aligned — see _collect_vertical_groups for the rationale. + if leaves and len(leaves) == len(node["children"]): + out.append((node, leaves)) + return # internals of a horizontal row are not peers of its siblings + for child in node.get("children", []): + _collect_horizontal_groups(child, out) + + +def _get_direct_leaves(node): + """Get direct leaf children (non-recursively) of a node.""" + leaves = [] + for child in node.get("children", []): + if not child.get("children"): + leaves.append(child) + return leaves + + +def _layout_translate(node, dx, dy): + """Translate node and all descendants by (dx, dy).""" + b = node["_bindings"] + node["_bindings"] = [b[0] + dx, b[1] + dy, b[2], b[3]] + for child in node.get("children", []): + _layout_translate(child, dx, dy) + + +def _find_leaf_centers_y(node): + """Collect Y-centers of all leaf nodes in a subtree.""" + if not node.get("children"): + b = node["_bindings"] + return [b[1] + b[3] // 2] + centers = [] + for child in node["children"]: + centers.extend(_find_leaf_centers_y(child)) + return centers + + +def _find_leaf_centers_x(node): + """Collect X-centers of all leaf nodes in a subtree.""" + if not node.get("children"): + b = node["_bindings"] + return [b[0] + b[2] // 2] + centers = [] + for child in node["children"]: + centers.extend(_find_leaf_centers_x(child)) + return centers + + +def _align_leaves_to_sibling_centers(ordered): + """For horizontal layout: align leaf Y-center to sibling groups' direct-child leaf Y-center. + + Prioritizes leaves from groups with the same direction (horizontal), + since those represent the main flow continuation. + """ + # Collect cy of direct-child leaves from sibling groups with same direction + same_dir_leaf_centers = [] + for child in ordered: + if child.get("children") and child.get("direction", "horizontal") == "horizontal": + for grandchild in child["children"]: + if not grandchild.get("children"): + b = grandchild["_bindings"] + same_dir_leaf_centers.append(b[1] + b[3] // 2) + + # Fallback: for each leaf, find the adjacent group and use its leaf median + if not same_dir_leaf_centers: + leaf_indices = [i for i, c in enumerate(ordered) if not c.get("children")] + group_indices = [i for i, c in enumerate(ordered) if c.get("children")] + if leaf_indices and group_indices: + # Use the group nearest to the first leaf + first_leaf_idx = leaf_indices[0] + nearest_group_idx = min(group_indices, key=lambda g: abs(g - first_leaf_idx)) + centers = _find_leaf_centers_y(ordered[nearest_group_idx]) + if centers: + same_dir_leaf_centers.append((min(centers) + max(centers)) // 2) + + if not same_dir_leaf_centers: + return + + target_cy = (min(same_dir_leaf_centers) + max(same_dir_leaf_centers)) // 2 + + for child in ordered: + if not child.get("children"): + b = child["_bindings"] + current_cy = b[1] + b[3] // 2 + dy = target_cy - current_cy + if dy != 0: + _layout_translate(child, 0, dy) + _align_branch_lane_anchors(ordered, target_cy, axis="y") + + +def _align_leaves_to_sibling_centers_h(ordered): + """For vertical layout: align leaf X-center to sibling groups' direct-child leaf X-center.""" + same_dir_leaf_centers = [] + for child in ordered: + if child.get("children") and child.get("direction", "horizontal") == "vertical": + for grandchild in child["children"]: + if not grandchild.get("children"): + b = grandchild["_bindings"] + same_dir_leaf_centers.append(b[0] + b[2] // 2) + + if not same_dir_leaf_centers: + for child in ordered: + if child.get("children"): + for grandchild in child["children"]: + if not grandchild.get("children"): + b = grandchild["_bindings"] + same_dir_leaf_centers.append(b[0] + b[2] // 2) + + if not same_dir_leaf_centers: + for child in ordered: + if child.get("children"): + centers = _find_leaf_centers_x(child) + if centers: + same_dir_leaf_centers.extend(centers) + + if not same_dir_leaf_centers: + return + + target_cx = (min(same_dir_leaf_centers) + max(same_dir_leaf_centers)) // 2 + + for child in ordered: + if not child.get("children"): + b = child["_bindings"] + current_cx = b[0] + b[2] // 2 + dx = target_cx - current_cx + if dx != 0: + _layout_translate(child, dx, 0) + _align_branch_lane_anchors(ordered, target_cx, axis="x") + + +def _align_branch_lane_anchors(ordered, target, axis): + """Shift each promoted branch lane so its ANCHOR (not the lane centroid) + sits on the main flow line. + + A branch lane (created by _promote_branch_nodes) stacks {anchor, branch} + perpendicular to the flow. Block-placement centres the lane's bounding box, + which pushes the anchor off the flow axis. We translate the whole lane so + the anchor's center returns to ``target`` and the branch hangs off to the + side, keeping the through-flow edge straight. axis "y" → vertical flow + offset (horizontal parent); axis "x" → horizontal offset (vertical parent). + """ + pos_idx = 1 if axis == "y" else 0 + size_idx = 3 if axis == "y" else 2 + for child in ordered: + aid = child.get("_branch_anchor") + if not aid or not child.get("children"): + continue + anchor = next((m for m in child["children"] if m.get("id") == aid), None) + if anchor is None: + continue + ab = anchor["_bindings"] + anchor_center = ab[pos_idx] + ab[size_idx] // 2 + delta = target - anchor_center + if delta != 0: + if axis == "y": + _layout_translate(child, 0, delta) + else: + _layout_translate(child, delta, 0) + + +def _layout_collect(node, nodes_out, groups_out, prefix=""): + """Collect flat node/group dicts from tree.""" + nid = prefix + node["id"] if prefix else node["id"] + b = node["_bindings"] + entry = {"x": b[0], "y": b[1], "width": b[2], "height": b[3]} + if node.get("label"): + entry["label"] = node["label"] + children = node.get("children", []) + if children: + child_ids = [prefix + node["id"] + "." + c["id"] if prefix else node["id"] + "." + c["id"] for c in children] + entry["children"] = child_ids + entry["direction"] = node.get("direction", "horizontal") + pad = node.get("_padding", {}) + entry["_padding"] = pad + if node.get("groupType"): + entry["groupType"] = node["groupType"] + groups_out[nid] = entry + for child in children: + _layout_collect(child, nodes_out, groups_out, nid + ".") + else: + if node.get("icon"): + entry["icon"] = node["icon"] + if node.get("box"): + entry["box"] = node["box"] + nodes_out[nid] = entry diff --git a/skill/sdpm/layout/refine.py b/skill/sdpm/layout/refine.py new file mode 100644 index 00000000..b46d567c --- /dev/null +++ b/skill/sdpm/layout/refine.py @@ -0,0 +1,1268 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +"""Route refinement: bend optimization, side reselection, pierce detours, +bend separation and fan-trunk rewriting. +""" + +from .geometry import ( + _GROUP_FRAME_INSET, + _PIERCE_WEIGHT, + _count_all_crossings, + _count_backwards, + _count_group_pierces, + _count_node_pierces, + _edge_free_bend, + _seg_crosses_box, + _seg_pierces_node, +) +from .model import ( + _auto_sides, + _elbow_path, + _find_endpoint, + _find_node, + _group_member_ids, + _port_point, +) + + + + +_MAX_RESOLVE_ITERATIONS = 30 +_BEND_CANDIDATES = [-120, -100, -80, -60, -50, -40, -30, -20, -10, 10, 20, 30, 40, 50, 60, 80, 100, 120] + + +_BEND_OPT_PASSES = 40 +_BEND_OPT_STEP = 4 +_BEND_OPT_INSET = 6 + + +def _optimize_bends(edges, nodes): + """Slide free elbow bends to minimize global crossings + weighted pierces. + + Coordinate descent: repeatedly try shifting each edge's free middle bend + to a range of candidate positions between its endpoints, keep the best. + Only the two middle points of a 4-point VHV/HVH path move, and only along + the trunk axis, so endpoints stay port-anchored and no diagonals appear. + Skips fan-out edges (they share a deliberate trunk) and detours. + """ + if not edges: + return edges + + def cost(es): + return _count_all_crossings(es) + _PIERCE_WEIGHT * _count_node_pierces(es, nodes) + + cur_cost = cost(edges) + for _ in range(_BEND_OPT_PASSES): + improved = False + for e in edges: + # A locked fan trunk must not move — sliding its free bend is what + # shifts the shared trunk line, which would break the merge the + # user explicitly requested. Leave it fixed. + if e.get("_fan_locked"): + continue + # Fan-out edges start on a shared trunk but are still free to be + # refined individually; optimizing them too reduces crossings + # without breaking axis-alignment (their middle bend is free). + fv = _edge_free_bend(e["points"]) + if not fv: + continue + axis, lo, hi = fv + lo, hi = min(lo, hi), max(lo, hi) + if hi - lo < 2 * _BEND_OPT_INSET: + continue + idx = 0 if axis == "x" else 1 + orig = e["points"][1][idx] + best_val = orig + best_cost = cur_cost + cand = int(lo) + _BEND_OPT_INSET + while cand < int(hi) - _BEND_OPT_INSET: + if cand != orig: + saved1, saved2 = e["points"][1][idx], e["points"][2][idx] + e["points"][1][idx] = cand + e["points"][2][idx] = cand + c = cost(edges) + if c < best_cost: + best_cost = c + best_val = cand + e["points"][1][idx] = saved1 + e["points"][2][idx] = saved2 + cand += _BEND_OPT_STEP + if best_val != orig: + e["points"][1][idx] = best_val + e["points"][2][idx] = best_val + cur_cost = best_cost + improved = True + if not improved: + break + return edges + + +def _optimize_single_bend(cand_pts, edge, edges, nodes): + """Return cand_pts with its single free elbow bend slid to lowest weighted + cost, evaluated against the live edge set (edge temporarily holds cand_pts). + + Used when judging a reselect candidate so we compare its BEST shape, not the + arbitrary mid-bend the router emits. Only the two middle points of a 4-point + VHV/HVH path move, along the trunk axis, so endpoints stay port-anchored. + """ + fv = _edge_free_bend(cand_pts) + if not fv: + return cand_pts + axis, lo, hi = fv + lo, hi = min(lo, hi), max(lo, hi) + if hi - lo < 2 * _BEND_OPT_INSET: + return cand_pts + idx = 0 if axis == "x" else 1 + saved = edge["points"] + best = [list(p) for p in cand_pts] + edge["points"] = best + best_w = _defect_weight((_count_all_crossings(edges), + _count_node_pierces(edges, nodes), + _count_backwards(edges, nodes))) + v = int(lo) + _BEND_OPT_INSET + while v < int(hi) - _BEND_OPT_INSET: + trial = [list(p) for p in cand_pts] + trial[1][idx] = v + trial[2][idx] = v + edge["points"] = trial + w = _defect_weight((_count_all_crossings(edges), + _count_node_pierces(edges, nodes), + _count_backwards(edges, nodes))) + if w < best_w: + best_w = w + best = trial + v += _BEND_OPT_STEP + edge["points"] = saved + return best + + +_SIDE_RESELECT_PASSES = 6 +# (port_index, port_count): center, then 1/4, 1/2, 3/4 along the edge. +_PORT_TRIALS = [(0, 1), (0, 3), (1, 3), (2, 3)] + +# Weights for comparing routing defects when reselecting sides. A pierce is the +# most visually damaging (a line cutting through an icon), so it outweighs a +# crossing; a backwards stub is the mildest. These mirror layout_qa.score(). +_DEFECT_W = (1.0, 1.5, 0.7) # (crossings, pierces, backwards) +# A line cutting through a framed group's box (without connecting to it or any +# icon inside) reads as broken. Weighted like a crossing — bad, but lighter +# than an icon pierce — and only steers the detour pass (it cannot un-pierce a +# box by changing port sides, only by routing around it). +_W_GROUP_PIERCE_ENGINE = 1.0 +# How many extra crossings a side change may introduce to clear a pierce. One +# crossing is an acceptable price to stop a line cutting through an icon. +_RESELECT_CROSS_SLACK = 1 + + +def _defect_weight(score): + """Weighted scalar of a (crossings, pierces, backwards) tuple; lower better.""" + return sum(w * s for w, s in zip(_DEFECT_W, score)) + + +def _candidate_side_pairs(src, dst): + """Geometrically sane (src_side, dst_side) pairs; natural pair first. + + A "sane" side points toward the target — never away from it (which would + force a backwards U-turn). For a target down-and-right of the source this + yields src in {right, bottom} and dst in {left, top}. + """ + s_cx = src["x"] + src.get("width", 60) / 2 + s_cy = src["y"] + src.get("height", 60) / 2 + d_cx = dst["x"] + dst.get("width", 60) / 2 + d_cy = dst["y"] + dst.get("height", 60) / 2 + src_sides, dst_sides = [], [] + if d_cx >= s_cx: + src_sides.append("right") + dst_sides.append("left") + if d_cx <= s_cx: + src_sides.append("left") + dst_sides.append("right") + if d_cy >= s_cy: + src_sides.append("bottom") + dst_sides.append("top") + if d_cy <= s_cy: + src_sides.append("top") + dst_sides.append("bottom") + pairs = [] + nat = _auto_sides(src, dst, None) + pairs.append(nat) + for s in dict.fromkeys(src_sides): + for d in dict.fromkeys(dst_sides): + if (s, d) not in pairs: + pairs.append((s, d)) + return pairs + + +def _is_axis_aligned(pts): + return all( + pts[k][0] == pts[k + 1][0] or pts[k][1] == pts[k + 1][1] + for k in range(len(pts) - 1) + ) + + +def _normalize_path(pts): + """Collapse a polyline's degenerate artifacts in place-safe form. + + Splicing jogs (and stacking several) can leave a path with: + - zero-length segments (consecutive identical points), and + - redundant collinear vertices (three points in a row on one axis), + which both read as a kink at a point that isn't really a corner and which + inflate the crossing count when a stray zero-length stub coincides with + another edge. This removes both without moving any real corner, so the + drawn shape is identical but minimal. Endpoints (pts[0], pts[-1]) are + preserved. Returns a new list; never shortens below 2 points. + """ + if len(pts) < 2: + return [list(p) for p in pts] + # 1) drop consecutive duplicates (zero-length segments) + dedup = [list(pts[0])] + for p in pts[1:]: + if p[0] != dedup[-1][0] or p[1] != dedup[-1][1]: + dedup.append(list(p)) + # 2) drop the middle of any three collinear points (same X or same Y run) + if len(dedup) <= 2: + return dedup + out = [dedup[0]] + for i in range(1, len(dedup) - 1): + a, b, c = out[-1], dedup[i], dedup[i + 1] + collinear_x = a[0] == b[0] == c[0] + collinear_y = a[1] == b[1] == c[1] + if collinear_x or collinear_y: + continue # b lies on the straight run a→c; skip it + out.append(b) + out.append(dedup[-1]) + return out + + +def _entry_exit_ok(pts, src_side, dst_side): + """First segment perpendicular to src edge, last to dst edge (no backwards). + + Rejects degenerate zero-length leading/trailing segments, which would + otherwise read as both horizontal and vertical and let a parallel + (non-perpendicular) run slip through. + """ + if len(pts) < 2: + return False + if pts[0] == pts[1] or pts[-1] == pts[-2]: + return False + first_h = pts[0][1] == pts[1][1] + last_h = pts[-1][1] == pts[-2][1] + src_h = src_side in ("left", "right") + dst_h = dst_side in ("left", "right") + return (first_h == src_h) and (last_h == dst_h) + + +def _edge_pierces(e, nodes): + """True if edge e passes through any non-endpoint icon interior.""" + pts = e["points"] + if len(pts) < 2: + return False + ignore = {e["from"], e["to"]} + for nid, n in nodes.items(): + short = nid.rsplit(".", 1)[-1] + if nid in ignore or short in ignore: + continue + if any(_seg_pierces_node(pts[k], pts[k + 1], n) for k in range(len(pts) - 1)): + return True + return False + + +def _edge_backwards(e, nodes): + """True if edge e has a first/last segment heading against its port normal.""" + return _count_backwards([e], nodes) > 0 + + +def _path_stability(pts): + """Tie-break key: prefer fewer, shorter segments.""" + length = sum( + abs(pts[k + 1][0] - pts[k][0]) + abs(pts[k + 1][1] - pts[k][1]) + for k in range(len(pts) - 1) + ) + return (len(pts), length) + + +def _reselect_sides(edges, nodes, obstacles): + """Remove pierces by re-choosing icon side/port, never by adding segments. + + For each still-piercing edge, re-route via the elbow router using + alternative (src_side, dst_side) pairs and port positions. Accept the + alternative only if it does not raise the global crossing count and + strictly lowers the global (crossings, pierces) tuple. Because crossings + is a hard ceiling, structural pierces (where every alternative raises + crossings) are correctly left untouched. Endpoints stay perpendicular + because they come from _port_point; no diagonals because _elbow_path only + emits H/V segments. + """ + for _ in range(_SIDE_RESELECT_PASSES): + piercing = [ + ei for ei, e in enumerate(edges) + if not e.get("_fanout") and not e.get("_fan_locked") + and len(e["points"]) >= 2 + and (_edge_pierces(e, nodes) or _edge_backwards(e, nodes)) + ] + piercing.sort(key=lambda ei: (edges[ei]["from"], edges[ei]["to"])) + committed = False + + for ei in piercing: + e = edges[ei] + src = _find_node(nodes, e["from"]) + dst = _find_node(nodes, e["to"]) + if not src or not dst: + continue + obs_excl = [o for o in obstacles if o.get("_node") not in (e["from"], e["to"])] + label_h = 30 if src.get("label") else 0 + + base = (_count_all_crossings(edges), _count_node_pierces(edges, nodes), + _count_backwards(edges, nodes)) + orig_pts = e["points"] + best_pts = None + best_score = base + + for (s_side, d_side) in _candidate_side_pairs(src, dst): + for (si, sc) in _PORT_TRIALS: + for (qi, qc) in _PORT_TRIALS: + sp = _port_point(src, s_side, si, sc, label_h) + tp = _port_point(dst, d_side, qi, qc, label_h) + cand = _elbow_path(sp, tp, s_side, d_side, obs_excl) + if not _is_axis_aligned(cand): + continue + if not _entry_exit_ok(cand, s_side, d_side): + continue + # Judge the candidate by its BEST achievable shape: slide + # its free trunk bend to the lowest-cost position before + # scoring. A bottom→top reroute past a row of icons looks + # bad at the default mid-bend but clears everything once + # the trunk is nudged into the gap — evaluate THAT. + cand = _optimize_single_bend(cand, e, edges, nodes) + e["points"] = cand + score = (_count_all_crossings(edges), _count_node_pierces(edges, nodes), + _count_backwards(edges, nodes)) + e["points"] = orig_pts + # A pierce (line through a non-endpoint icon) reads worse + # than a crossing, so judge candidates by a WEIGHTED total + # (pierce 1.5 > cross 1.0 > backwards 0.7) rather than a + # strict crossings-first ceiling. This lets a still-piercing + # edge clear the icon even when doing so adds one crossing, + # matching the layout_qa objective. A guard still rejects + # trades that pile on crossings (more than +_RESELECT_CROSS_SLACK). + if score[0] > base[0] + _RESELECT_CROSS_SLACK: + continue + better = ( + _defect_weight(score) < _defect_weight(best_score) + or (best_pts is not None + and _defect_weight(score) == _defect_weight(best_score) + and _path_stability(cand) < _path_stability(best_pts)) + ) + if better: + best_score = score + best_pts = cand + + if (best_pts is not None + and _defect_weight(best_score) < _defect_weight(base) + and best_score[0] <= base[0] + _RESELECT_CROSS_SLACK): + e["points"] = best_pts + committed = True + + if not committed: + break + return edges + + +_DETOUR_FACE_MARGIN = 18 +_DETOUR_PASSES = 6 +_JOG_ARM_STEP = 12 + + +def _optimize_jog_arm(cand, k, edge, edges, nodes): + """Slide a freshly-spliced jog arm to its lowest-cost parallel position. + + A jog splices 4 points at index k+1: [arm_a, corner_a, corner_b, arm_b]. + The two corners share one free coordinate (the arm's offset from the + pierced segment) — x for a jog off a vertical segment, y for a horizontal + one. The raw candidate hugs the obstacle face; sliding the arm outward can + clear other edges it would otherwise cross. We scan a range of offsets and + keep the one with the lowest weighted defect, evaluated against the live + edge set. Endpoints (arm_a, arm_b) stay put, so the splice remains interior + and axis-aligned. + """ + if len(cand) < k + 5: + return cand + ca, cb = cand[k + 2], cand[k + 3] + # Determine the free axis: corners share x (vertical-seg jog) or y (horiz). + if ca[0] == cb[0]: + axis = 0 # corners share X — slide X + elif ca[1] == cb[1]: + axis = 1 # corners share Y — slide Y + else: + return cand # not a clean bracket + + def weighted(pts_override): + saved = edge["points"] + edge["points"] = pts_override + s = (_count_all_crossings(edges), _count_node_pierces(edges, nodes), + _count_backwards(edges, nodes)) + edge["points"] = saved + return _defect_weight(s) + + base_val = ca[axis] + best = cand + best_w = weighted(cand) + # Search outward on both sides of the current arm offset. + for delta in range(-120, 121, _JOG_ARM_STEP): + if delta == 0: + continue + v = base_val + delta + trial = [list(p) for p in cand] + trial[k + 2][axis] = v + trial[k + 3][axis] = v + if not _is_axis_aligned(trial): + continue + # The arm must not now pierce the very obstacle it was meant to clear, + # nor any other — that is captured by the pierce term in the weight. + w = weighted(trial) + if w < best_w: + best_w = w + best = trial + return best + + +def _jog_candidates(seg_a, seg_b, n): + """Axis-aligned bracket detours around node n for piercing segment a->b. + + Returns replacement point-lists that splice into the segment interior: + a -> (parallel run past one face of n) -> b. Every introduced segment is + horizontal or vertical, and seg_a/seg_b are preserved verbatim, so true + endpoints (when a/b are pts[0]/pts[-1]) never move. A candidate is + discarded when the obstacle extends past the segment's own span (the jog + would need to move an endpoint), guaranteeing the splice stays interior. + """ + nx0, ny0 = n["x"], n["y"] + nx1 = nx0 + n.get("width", 60) + ny1 = ny0 + n.get("height", n.get("width", 60)) + m = _DETOUR_FACE_MARGIN + out = [] + if seg_a[0] == seg_b[0]: # vertical segment at x=X -> jog left/right + x = seg_a[0] + y_lo, y_hi = min(seg_a[1], seg_b[1]), max(seg_a[1], seg_b[1]) + # Bracket arms run parallel just past the obstacle's vertical extent, + # clamped to stay strictly inside the segment span so the splice never + # moves an endpoint. If the obstacle protrudes past an end, clamp the + # arm to that endpoint (collapsing the stub to zero length there). + b_lo = max(y_lo, ny0 - m) + b_hi = min(y_hi, ny1 + m) + if b_lo >= b_hi: + return out # no overlap to bracket + for cx in (nx0 - m, nx1 + m): + out.append([list(seg_a), [x, b_lo], [cx, b_lo], [cx, b_hi], [x, b_hi], list(seg_b)]) + elif seg_a[1] == seg_b[1]: # horizontal segment at y=Y -> jog up/down + y = seg_a[1] + x_lo, x_hi = min(seg_a[0], seg_b[0]), max(seg_a[0], seg_b[0]) + b_lo = max(x_lo, nx0 - m) + b_hi = min(x_hi, nx1 + m) + if b_lo >= b_hi: + return out + for cy in (ny0 - m, ny1 + m): + out.append([list(seg_a), [b_lo, y], [b_lo, cy], [b_hi, cy], [b_hi, y], list(seg_b)]) + return out + + +def _detour_around_pierces(edges, nodes, groups=None): + """Splice obstacle jogs to clear pierces no side/port choice can fix. + + Obstacles are both non-endpoint ICONS and framed GROUP boxes that an edge + cuts through without connecting to (group-frame pierce). The same bracket + jog clears either — a box is just a wider obstacle. Group pierces feed the + weighted cost so a detour around a frame is taken when it does not cost more + crossings/icon-pierces than it saves. + + Greedy, one commit at a time, re-measuring the full live edge set after + every tentative change. A jog is committed only if the global + (crossings, pierces) tuple strictly improves AND crossings does not rise. + This makes crossings monotone non-increasing — the separate-pass blow-up + (where locally-accepted jogs interacted to raise global crossings) cannot + recur. Structural pierces, whose every jog raises crossings, are left. + """ + if not edges: + return edges + + # Framed groups an edge may need to detour around (box obstacles). + framed = [(gid, g) for gid, g in (groups or {}).items() if g.get("groupType")] + + def cost(es): + gp = _count_group_pierces(es, groups, nodes) if framed else 0 + return (_count_all_crossings(es), + _count_node_pierces(es, nodes) + _W_GROUP_PIERCE_ENGINE * gp, + _count_backwards(es, nodes)) + + for _ in range(_DETOUR_PASSES): + cur = cost(edges) + if cur[1] == 0: + break + improved = False + + for e in edges: + # A locked fan trunk must keep its shape — splicing a jog into it + # would bend the shared trunk and break the merge. Skip it; other + # edges detour around it instead. + if e.get("_fan_locked"): + continue + # Fan-out edges are eligible: a jog around an obstacle does not + # break the shared-trunk concept, and the global gate below only + # commits it when it strictly helps. + pts = e["points"] + if len(pts) < 2: + continue + ignore = {e["from"], e["to"]} + # Box obstacles this edge must avoid: framed groups it neither + # connects to nor has a member endpoint in. + efrom = e["from"].rsplit(".", 1)[-1] + eto = e["to"].rsplit(".", 1)[-1] + box_obstacles = [] + for gid, g in framed: + gshort = gid.rsplit(".", 1)[-1] + if efrom == gshort or eto == gshort: + continue + members = _group_member_ids(nodes, groups, gid) + if efrom in members or eto in members: + continue + box_obstacles.append(g) + # A box detour is only worth taking if it removes ALL frame pierces + # this edge causes — a partial detour that still clips a box just + # adds wire/bends for a still-broken look (the microservices + # bus-through-services case). Icon-pierce jogs keep their original + # partial-improvement behaviour. + base = cost(edges) + best_pts = None + best_score = base + # Scan each segment for a pierced obstacle; build jog candidates. + for k in range(len(pts) - 1): + seg_a, seg_b = pts[k], pts[k + 1] + # Obstacles for this segment: non-endpoint icons it pierces, plus + # framed group boxes it cuts through. + hit_obs = [] + for nid, n in nodes.items(): + short = nid.rsplit(".", 1)[-1] + if nid in ignore or short in ignore: + continue + if _seg_pierces_node(seg_a, seg_b, n): + hit_obs.append((n, False)) + for g in box_obstacles: + if _seg_crosses_box(seg_a, seg_b, g["x"], g["y"], + g["width"], g["height"], _GROUP_FRAME_INSET): + hit_obs.append((g, True)) + for n, is_box in hit_obs: + for repl in _jog_candidates(seg_a, seg_b, n): + cand = pts[:k + 1] + repl[1:-1] + pts[k + 1:] + if not _is_axis_aligned(cand): + continue + # The raw jog hugs the obstacle's face; that arm position + # may cross other edges. Slide the jog arm to its best + # position FIRST, then judge — mirrors evaluating a config + # by its post-bend-optimization quality, not its raw form. + cand = _optimize_jog_arm(cand, k, e, edges, nodes) + # Strip zero-length / collinear artifacts the splice (or + # a previously-committed jog stacked on this segment) may + # have left, so a stray stub can't fake a crossing and the + # committed shape is minimal. + cand = _normalize_path(cand) + saved = e["points"] + e["points"] = cand + score = cost(edges) + # A box detour must fully clear this edge's frame + # pierces; a partial escape is rejected so we never + # commit a longer, still-piercing route. + box_pierce_after = (_count_group_pierces([e], groups, nodes) + if box_obstacles else 0) + e["points"] = saved + # Box detour: accept ONLY when it fully clears this + # edge of every frame it cut through. A still-clipping + # detour (after > 0) is the structural case the user + # must fix by restructuring — leave it untouched and let + # the warning flag it. + if is_box and box_pierce_after != 0: + continue + # Judge by weighted defect (pierce 1.5 > cross 1.0): a jog + # may add up to _RESELECT_CROSS_SLACK crossings to lift a + # line off an icon it cuts through, which reads far worse + # than a crossing. Mirrors _reselect_sides. + if score[0] > base[0] + _RESELECT_CROSS_SLACK: + continue + if _defect_weight(score) < _defect_weight(best_score) or ( + best_pts is not None + and _defect_weight(score) == _defect_weight(best_score) + and _path_stability(cand) < _path_stability(best_pts) + ): + best_score = score + best_pts = cand + if (best_pts is not None + and _defect_weight(best_score) < _defect_weight(base) + and best_score[0] <= base[0] + _RESELECT_CROSS_SLACK): + e["points"] = best_pts + improved = True + + if not improved: + break + return edges + + +_MIN_BEND_SEPARATION = 40 + + +def _resolve_crossing_search(edges, i, si, j, sj): + """Try all candidate shifts for both crossing edges and pick the best. + + Scoring: (crossings, -min_bend_separation, displacement) + 1. Minimize total crossings (most important) + 2. Maximize minimum distance between parallel bends (visual clarity) + 3. Minimize displacement from original position (stability) + """ + import copy + + pts_i = edges[i]["points"] + pts_j = edges[j]["points"] + a1, a2 = pts_i[si], pts_i[si + 1] + b1, b2 = pts_j[sj], pts_j[sj + 1] + + current_crossings = _count_all_crossings(edges) + current_separation = _min_bend_separation(edges) + best_score = (current_crossings, -current_separation, 0) + best_patch = None + + candidates = [] + for edge_idx, seg_idx, pt1, pt2 in [(i, si, a1, a2), (j, sj, b1, b2)]: + is_vert = pt1[0] == pt2[0] + is_horiz = pt1[1] == pt2[1] + if is_vert: + for delta in _BEND_CANDIDATES: + candidates.append((edge_idx, "x", seg_idx, delta)) + if is_horiz: + for delta in _BEND_CANDIDATES: + candidates.append((edge_idx, "y", seg_idx, delta)) + + for edge_idx, axis, seg, delta in candidates: + test_edges = copy.deepcopy(edges) + pts = test_edges[edge_idx]["points"] + + if axis == "x": + _apply_bend_shift_x(pts, seg, delta) + else: + _apply_bend_shift_y(pts, seg, delta) + + crossings = _count_all_crossings(test_edges) + separation = _min_bend_separation(test_edges) + score = (crossings, -separation, abs(delta)) + + if score < best_score: + best_score = score + best_patch = (edge_idx, copy.deepcopy(test_edges[edge_idx]["points"])) + + if best_patch is not None: + edge_idx, new_pts = best_patch + edges[edge_idx]["points"] = new_pts + + +def _min_bend_separation(edges): + """Calculate the minimum distance between parallel bend segments. + + Checks all pairs of vertical bends (same-ish Y range) for X separation, + and all pairs of horizontal bends (same-ish X range) for Y separation. + Returns the minimum separation found (larger = better visual clarity). + """ + vertical_bends = [] + horizontal_bends = [] + + for e in edges: + pts = e["points"] + if len(pts) < 4: + continue + for k in range(len(pts) - 1): + p1, p2 = pts[k], pts[k + 1] + if p1[0] == p2[0] and abs(p1[1] - p2[1]) > 10: + y_min, y_max = min(p1[1], p2[1]), max(p1[1], p2[1]) + vertical_bends.append((p1[0], y_min, y_max)) + elif p1[1] == p2[1] and abs(p1[0] - p2[0]) > 10: + x_min, x_max = min(p1[0], p2[0]), max(p1[0], p2[0]) + horizontal_bends.append((p1[1], x_min, x_max)) + + min_sep = 9999 + + for a in range(len(vertical_bends)): + for b in range(a + 1, len(vertical_bends)): + ax, ay_min, ay_max = vertical_bends[a] + bx, by_min, by_max = vertical_bends[b] + overlap_y = min(ay_max, by_max) - max(ay_min, by_min) + if overlap_y > 10: + sep = abs(ax - bx) + if sep < min_sep: + min_sep = sep + + for a in range(len(horizontal_bends)): + for b in range(a + 1, len(horizontal_bends)): + ay, ax_min, ax_max = horizontal_bends[a] + by, bx_min, bx_max = horizontal_bends[b] + overlap_x = min(ax_max, bx_max) - max(ax_min, bx_min) + if overlap_x > 10: + sep = abs(ay - by) + if sep < min_sep: + min_sep = sep + + return min_sep + + +def _separate_close_bends(edges): + """Spread parallel bends that are too close, distributing them evenly. + + Groups vertical bends that share a similar X position (within _MIN_BEND_SEPARATION) + AND whose Y ranges are adjacent or overlapping. Spreads their X positions evenly + with _MIN_BEND_SEPARATION between each. + Does not introduce new crossings. + """ + import copy + + # Collect all vertical bend segments: (edge_idx, seg_idx, x, y_min, y_max) + v_bends = [] + for ei, e in enumerate(edges): + if e.get("_fanout"): + continue + pts = e["points"] + for k in range(len(pts) - 1): + if pts[k][0] == pts[k + 1][0] and abs(pts[k][1] - pts[k + 1][1]) > 10: + y_min = min(pts[k][1], pts[k + 1][1]) + y_max = max(pts[k][1], pts[k + 1][1]) + v_bends.append((ei, k, pts[k][0], y_min, y_max)) + + # Group bends that are close in X AND adjacent/overlapping in Y + # BUT: only group bends from DIFFERENT source nodes. + # Bends from the same source should be aligned (not separated). + used = set() + groups = [] + for a in range(len(v_bends)): + if a in used: + continue + group = [a] + group_y_min = v_bends[a][3] + group_y_max = v_bends[a][4] + src_a = edges[v_bends[a][0]]["from"] + for b in range(a + 1, len(v_bends)): + if b in used: + continue + ei_b = v_bends[b][0] + src_b = edges[ei_b]["from"] + # Skip if same source — those should stay aligned + if src_b == src_a: + continue + _, _, bx, by_min, by_max = v_bends[b] + group_x_avg = sum(v_bends[idx][2] for idx in group) // len(group) + if abs(bx - group_x_avg) >= _MIN_BEND_SEPARATION: + continue + gap = max(by_min - group_y_max, group_y_min - by_max) + if gap < 50: + group.append(b) + group_y_min = min(group_y_min, by_min) + group_y_max = max(group_y_max, by_max) + if len(group) < 2: + continue + used.update(group) + groups.append(group) + + # Spread each group evenly + for group in groups: + group_bends = [(v_bends[idx], idx) for idx in group] + group_bends.sort(key=lambda t: (t[0][3] + t[0][4]) / 2) + center_x = sum(v[0][2] for v in group_bends) // len(group_bends) + spread_total = _MIN_BEND_SEPARATION * (len(group_bends) - 1) + start_x = center_x - spread_total // 2 + + current_crossings = _count_all_crossings(edges) + for slot, (bend_info, _) in enumerate(group_bends): + ei, seg_k, old_x, _, _ = bend_info + new_x = start_x + slot * _MIN_BEND_SEPARATION + if new_x == old_x: + continue + test_edges = copy.deepcopy(edges) + delta = new_x - old_x + _apply_bend_shift_x(test_edges[ei]["points"], seg_k, delta) + if _count_all_crossings(test_edges) <= current_crossings: + _apply_bend_shift_x(edges[ei]["points"], seg_k, delta) + + # Align bends from the same source to a single X position + _align_same_source_bends(edges) + + # Also spread close horizontal segments + _separate_close_horizontal_segments(edges) + + +def _align_same_source_bends(edges): + """Align bends from the same source node to a single X (or Y) position. + + When multiple edges fan out from the same node, their vertical bends + should share the same X so they look like a clean tree branch. + Only aligns if it doesn't introduce new crossings. + """ + import copy + + # Group edges by source + src_groups = {} + for ei, e in enumerate(edges): + pts = e["points"] + if len(pts) < 4: + continue + src_groups.setdefault(e["from"], []).append(ei) + + current_crossings = _count_all_crossings(edges) + + for src, edge_indices in src_groups.items(): + if len(edge_indices) < 2: + continue + + # Skip alignment if start Y positions differ (fan-out with distributed ports) + start_ys = [edges[ei]["points"][0][1] for ei in edge_indices] + if max(start_ys) - min(start_ys) > 10: + continue + + # Collect vertical bend X positions for these edges + bend_xs = [] + for ei in edge_indices: + pts = edges[ei]["points"] + for k in range(len(pts) - 1): + if pts[k][0] == pts[k + 1][0] and abs(pts[k][1] - pts[k + 1][1]) > 5: + bend_xs.append((ei, k, pts[k][0])) + break + + if len(bend_xs) < 2: + continue + + # All already aligned? + xs = [x for _, _, x in bend_xs] + if max(xs) - min(xs) <= 5: + continue + + # Try aligning to the median X + target_x = sorted(xs)[len(xs) // 2] + + # Test: align all to target_x + test_edges = copy.deepcopy(edges) + for ei, k, old_x in bend_xs: + if old_x != target_x: + delta = target_x - old_x + _apply_bend_shift_x(test_edges[ei]["points"], k, delta) + + if _count_all_crossings(test_edges) <= current_crossings: + for ei, k, old_x in bend_xs: + if old_x != target_x: + delta = target_x - old_x + _apply_bend_shift_x(edges[ei]["points"], k, delta) + + # Same for destination (fan-in): align bends going to the same target + dst_groups = {} + for ei, e in enumerate(edges): + pts = e["points"] + if len(pts) < 4: + continue + dst_groups.setdefault(e["to"], []).append(ei) + + current_crossings = _count_all_crossings(edges) + + for dst, edge_indices in dst_groups.items(): + if len(edge_indices) < 2: + continue + + bend_xs = [] + for ei in edge_indices: + pts = edges[ei]["points"] + for k in range(len(pts) - 1): + if pts[k][0] == pts[k + 1][0] and abs(pts[k][1] - pts[k + 1][1]) > 5: + bend_xs.append((ei, k, pts[k][0])) + break + + if len(bend_xs) < 2: + continue + + xs = [x for _, _, x in bend_xs] + if max(xs) - min(xs) <= 5: + continue + + target_x = sorted(xs)[len(xs) // 2] + + test_edges = copy.deepcopy(edges) + for ei, k, old_x in bend_xs: + if old_x != target_x: + delta = target_x - old_x + _apply_bend_shift_x(test_edges[ei]["points"], k, delta) + + if _count_all_crossings(test_edges) <= current_crossings: + for ei, k, old_x in bend_xs: + if old_x != target_x: + delta = target_x - old_x + _apply_bend_shift_x(edges[ei]["points"], k, delta) + + +def _separate_close_horizontal_segments(edges): + """Detect horizontal segments at nearly the same Y with overlapping X range. + + When two horizontal segments from different edges are within + _MIN_BEND_SEPARATION/2 in Y and overlap in X, shift one edge's bend + to create visual separation. + """ + import copy + + # Collect all horizontal segments: (edge_idx, seg_idx, y, x_min, x_max) + h_segs = [] + for ei, e in enumerate(edges): + pts = e["points"] + for k in range(len(pts) - 1): + if pts[k][1] == pts[k + 1][1] and abs(pts[k][0] - pts[k + 1][0]) > 20: + x_min = min(pts[k][0], pts[k + 1][0]) + x_max = max(pts[k][0], pts[k + 1][0]) + h_segs.append((ei, k, pts[k][1], x_min, x_max)) + + current_crossings = _count_all_crossings(edges) + adjusted = set() + for a in range(len(h_segs)): + for b in range(a + 1, len(h_segs)): + ei_a, k_a, y_a, xmin_a, xmax_a = h_segs[a] + ei_b, k_b, y_b, xmin_b, xmax_b = h_segs[b] + if ei_a == ei_b: + continue + y_diff = abs(y_a - y_b) + if y_diff >= _MIN_BEND_SEPARATION // 2: + continue + overlap = min(xmax_a, xmax_b) - max(xmin_a, xmin_b) + if overlap <= 20: + continue + + # Try shifting either edge's vertical bend X to shorten/lengthen + # the horizontal segment so they no longer overlap in X. + resolved = False + for ei, k in [(ei_a, k_a), (ei_b, k_b)]: + if ei in adjusted or resolved: + continue + pts = edges[ei]["points"] + # Find the vertical bend in this edge + for vk in range(len(pts) - 1): + if pts[vk][0] == pts[vk + 1][0] and abs(pts[vk][1] - pts[vk + 1][1]) > 5: + # Try shifting this bend X to reduce horizontal overlap + other_xmin = xmin_b if ei == ei_a else xmin_a + other_xmax = xmax_b if ei == ei_a else xmax_a + # Shift bend to just before or after the other segment + for delta in [-60, -40, 60, 40, -80, 80, -100, 100]: + test_edges = copy.deepcopy(edges) + _apply_bend_shift_x(test_edges[ei]["points"], vk, delta) + # Check: overlap reduced AND no new crossings + new_crossings = _count_all_crossings(test_edges) + # Recalculate overlap + new_pts = test_edges[ei]["points"] + for nk in range(len(new_pts) - 1): + if new_pts[nk][1] == new_pts[nk + 1][1] and abs(new_pts[nk][0] - new_pts[nk + 1][0]) > 20: + new_xmin = min(new_pts[nk][0], new_pts[nk + 1][0]) + new_xmax = max(new_pts[nk][0], new_pts[nk + 1][0]) + if abs(new_pts[nk][1] - (y_b if ei == ei_a else y_a)) < _MIN_BEND_SEPARATION // 2: + new_overlap = min(new_xmax, other_xmax) - max(new_xmin, other_xmin) + if new_overlap <= 20 and new_crossings <= current_crossings: + _apply_bend_shift_x(edges[ei]["points"], vk, delta) + adjusted.add(ei) + resolved = True + break + if resolved: + break + break + + +def _apply_bend_shift_x(points, seg_idx, delta): + """Shift the vertical bend at seg_idx by delta on the X axis. + + Never moves the first or last point (port-anchored endpoints). + """ + if len(points) < 3: + return + p1 = points[seg_idx] + p2 = points[min(seg_idx + 1, len(points) - 1)] + if p1[0] == p2[0]: + target_x = p1[0] + elif seg_idx > 0 and points[seg_idx - 1][0] == p1[0]: + target_x = p1[0] + else: + target_x = p1[0] + + for i, pt in enumerate(points): + if i == 0 or i == len(points) - 1: + continue + if pt[0] == target_x: + pt[0] += delta + + +def _apply_bend_shift_y(points, seg_idx, delta): + """Shift the horizontal bend at seg_idx by delta on the Y axis. + + Never moves the first or last point (port-anchored endpoints). + Only shifts points that are part of an internal horizontal segment + (not adjacent to the start/end points). + """ + if len(points) < 4: + return + p1 = points[seg_idx] + p2 = points[min(seg_idx + 1, len(points) - 1)] + if p1[1] == p2[1]: + target_y = p1[1] + elif seg_idx > 0 and points[seg_idx - 1][1] == p1[1]: + target_y = p1[1] + else: + target_y = p1[1] + + # Don't shift if target_y matches start or end Y (would break port alignment) + if target_y == points[0][1] or target_y == points[-1][1]: + return + + for i, pt in enumerate(points): + if i == 0 or i == len(points) - 1: + continue + if pt[1] == target_y: + pt[1] += delta + + +def _update_bend_x(points, old_x, new_x): + """Update bend X coordinate in a 4-point elbow path.""" + for pt in points: + if abs(pt[0] - old_x) < 3: + pt[0] = new_x + + +_FAN_BEND_MARGIN = 30 +# How far past a framed group's edge the fan trunk is pushed so the split/merge +# happens clearly outside the box, not flush against the frame. +_FAN_GROUP_CLEARANCE = 22 + + +def _enclosing_framed_group(groups, nodes, node_id): + """Return the geometry of the framed group that directly encloses node_id. + + A fan hub that lives inside a drawn box should split/merge OUTSIDE that box. + We find the framed (groupType) group whose member set contains node_id and, + if several nest, pick the SMALLEST (innermost) by area — that is the frame + the trunk must clear first. Returns the group dict or None. + """ + if not groups: + return None + short = node_id.rsplit(".", 1)[-1] + best = None + for gid, g in groups.items(): + if not g.get("groupType"): + continue + members = _group_member_ids(nodes, groups, gid) + if short in members: + area = g["width"] * g["height"] + if best is None or area < best[0]: + best = (area, g) + return best[1] if best else None + + +def _push_trunk_outside_group(trunk_v, side, vertical, nearest, hbox): + """Shift a fan trunk coordinate to just past the hub's enclosing frame. + + The trunk is the shared line where the bundle splits (fan-out) or merges + (fan-in). When the hub sits inside a framed box, a trunk flush against the + icon still bends inside the frame. Push it past the frame edge it exits + through (by _FAN_GROUP_CLEARANCE), but clamp so it never reaches/over­shoots + the nearest spoke — leaving the spoke side of the gap for the actual fan. + No-op when there is no enclosing frame or the push would cross the spoke. + """ + if hbox is None: + return trunk_v + if vertical: + edge = hbox["y"] + hbox["height"] if side == "bottom" else hbox["y"] + else: + edge = hbox["x"] + hbox["width"] if side == "right" else hbox["x"] + if side in ("right", "bottom"): + target = edge + _FAN_GROUP_CLEARANCE + # only push outward, and stay short of the nearest spoke + if target > trunk_v and target < nearest: + return target + else: # left / top — frame edge is on the smaller-coordinate side + target = edge - _FAN_GROUP_CLEARANCE + if target < trunk_v and target > nearest: + return target + return trunk_v + + +def _fan_side_vote(edges, conn_sides, indices, mode, nodes=None, groups=None): + """Pick the single shared hub side for a fan group from GEOMETRY. + + The hub end (src for fan-out, dst for fan-in) must agree on ONE side so all + edges leave/enter through one unified port. We choose the box edge that + faces the spokes' centroid: e.g. a hub directly BELOW a row of spokes is + entered through its TOP. This is far more robust than the old majority vote + over per-edge router sides, which picked "right" for a hub sitting squarely + below its sources (each spoke saw a different diagonal direction). + """ + hub_id = edges[indices[0]]["from"] if mode == "fan_out" else edges[indices[0]]["to"] + hub, _ = _find_endpoint(nodes or {}, groups or {}, hub_id) + spokes = [] + for i in indices: + sid = edges[i]["to"] if mode == "fan_out" else edges[i]["from"] + s, _ = _find_endpoint(nodes or {}, groups or {}, sid) + if s is not None: + spokes.append(s) + if hub is not None and spokes: + hcx = hub["x"] + hub["width"] / 2 + hcy = hub["y"] + hub["height"] / 2 + scx = sum(s["x"] + s["width"] / 2 for s in spokes) / len(spokes) + scy = sum(s["y"] + s["height"] / 2 for s in spokes) / len(spokes) + dx, dy = scx - hcx, scy - hcy # direction from hub toward spokes + if abs(dx) >= abs(dy): + return "right" if dx > 0 else "left" + return "bottom" if dy > 0 else "top" + + # Fallback: majority vote over router-chosen sides. + pref = {"right": 0, "left": 1, "bottom": 2, "top": 3} + votes = {} + for i in indices: + _, _, src_side, dst_side = conn_sides[i] + side = src_side if mode == "fan_out" else dst_side + if side: + votes[side] = votes.get(side, 0) + 1 + if not votes: + return "right" + return sorted(votes.items(), key=lambda kv: (-kv[1], pref.get(kv[0], 9)))[0][0] + + +def _rewrite_fan(edges, conn_sides, indices, mode, nodes=None, groups=None): + """Force a fan-out/fan-in group onto a unified port and a shared trunk. + + The merge is a hard constraint (the LLM asked for it), so we rebuild every + edge in the group from scratch as a clean 4-point elbow: + - one shared port on the hub node (computed from node geometry, centered), + - a shared trunk coordinate (all edges bend at the same line), + - the spoke then peels off to each individual target/source. + The hub may be a NODE or a GROUP (box) — both expose x/y/width/height, so a + group hub gets a single shared port on its box edge just like a node. Edges + that had become detours (len>4) are rebuilt too. Each edge is tagged + `_fan_locked` so downstream optimizers don't undo the alignment. + """ + if not indices: + return + side = _fan_side_vote(edges, conn_sides, indices, mode, nodes, groups) + vertical = side in ("top", "bottom") + + # Resolve the hub (shared end) — node OR group — and its geometry. + hub_id = edges[indices[0]]["from"] if mode == "fan_out" else edges[indices[0]]["to"] + hub, hub_is_group = _find_endpoint(nodes or {}, groups or {}, hub_id) + + # Unified port point on the hub edge, centered along that edge. + if hub is not None: + hx, hy, hw, hh = hub["x"], hub["y"], hub["width"], hub["height"] + # A group port sits on the box edge (no label band offset). + label_h = 0 if hub_is_group else (30 if hub.get("label") else 0) + if side == "right": + port = [hx + hw, hy + hh // 2] + elif side == "left": + port = [hx, hy + hh // 2] + elif side == "bottom": + port = [hx + hw // 2, hy + hh + label_h] + else: # top + port = [hx + hw // 2, hy] + else: + # Fall back to averaging the existing ports if geometry is unavailable. + ends = [edges[j]["points"][0] if mode == "fan_out" else edges[j]["points"][-1] + for j in indices] + port = [sum(p[0] for p in ends) // len(ends), sum(p[1] for p in ends) // len(ends)] + + # Shared trunk coordinate: a line in the GAP between the hub port and the + # nearest spoke. It must stay strictly between the two — if the gap is + # narrower than the preferred margin, fall back to the midpoint rather than + # overshooting the spoke (which would drive the trunk into the spoke icons, + # the bug that made stacked fan-outs pierce their targets). + spoke_ends = [edges[j]["points"][-1] if mode == "fan_out" else edges[j]["points"][0] + for j in indices] + + def _gap_trunk(p0, nearest): + # p0 = hub port coordinate, nearest = closest spoke coordinate. + lo, hi = (p0, nearest) if p0 <= nearest else (nearest, p0) + mid = (p0 + nearest) // 2 + if hi - lo <= 2 * _FAN_BEND_MARGIN: + return mid # gap too tight for the margin → sit in the middle + # otherwise sit _FAN_BEND_MARGIN away from the hub, toward the spoke + return p0 + _FAN_BEND_MARGIN if p0 < nearest else p0 - _FAN_BEND_MARGIN + + if vertical: + spoke_vs = [p[1] for p in spoke_ends] + nearest = min(spoke_vs) if side == "bottom" else max(spoke_vs) + trunk_v = _gap_trunk(port[1], nearest) + else: + spoke_hs = [p[0] for p in spoke_ends] + nearest = min(spoke_hs) if side == "right" else max(spoke_hs) + trunk_v = _gap_trunk(port[0], nearest) + + # Keep the split/merge OUTSIDE the hub's framed group. When the hub icon + # lives inside a drawn box (e.g. EventBridge inside "Orchestration"), a + # trunk sitting just past the icon still bends WHILE inside the frame, so + # the fan visibly branches within an unrelated container. Push the trunk + # past the frame edge it exits through (plus a margin) so the bundle leaves + # the box as one line and only fans out beyond it — but never past the + # nearest spoke (that would drive the trunk into the targets). Only applies + # when the hub is a NODE enclosed by a framed group on the exit side. + if not hub_is_group and groups: + trunk_v = _push_trunk_outside_group( + trunk_v, side, vertical, nearest, + _enclosing_framed_group(groups, nodes, hub_id)) + + # The spoke nodes (the N individual ends) must ALSO leave/enter through a + # consistent edge — the one facing the trunk. A fan-in to a trunk BELOW the + # agents means every agent exits its BOTTOM edge (not whichever side the + # router first picked, which left planner exiting "right" and coder "left"). + # The spoke side is the side facing the trunk: opposite the hub side for the + # spoke's own port normal. + def spoke_port(node, sside, is_group): + nx, ny, nw, nh = node["x"], node["y"], node["width"], node["height"] + nlabel_h = 0 if is_group else (30 if node.get("label") else 0) + if sside == "bottom": + return [nx + nw // 2, ny + nh + nlabel_h] + if sside == "top": + return [nx + nw // 2, ny] + if sside == "right": + return [nx + nw, ny + nh // 2] + return [nx, ny + nh // 2] # left + + for i in indices: + # spoke end = the per-edge individual end (target for fan-out, source for + # fan-in); it may itself be a node OR a group. + spoke_id = edges[i]["to"] if mode == "fan_out" else edges[i]["from"] + spoke_node, spoke_is_group = _find_endpoint(nodes or {}, groups or {}, spoke_id) + pts = edges[i]["points"] + + # Decide which spoke edge faces the trunk. The trunk is a line on the + # `side` axis relative to the hub; the spoke must exit toward it. + if vertical: + # trunk is a horizontal line at y=trunk_v; spoke exits bottom if it + # sits above the trunk, else top. + ref = (spoke_node["y"] + spoke_node["height"] // 2) if spoke_node else pts[0][1] + s_side = "bottom" if trunk_v >= ref else "top" + else: + ref = (spoke_node["x"] + spoke_node["width"] // 2) if spoke_node else pts[0][0] + s_side = "right" if trunk_v >= ref else "left" + + if spoke_node is not None: + spoke_pt = spoke_port(spoke_node, s_side, spoke_is_group) + else: + spoke_pt = list(pts[-1] if mode == "fan_out" else pts[0]) + + if mode == "fan_out": + tgt = spoke_pt + if vertical: + edges[i]["points"] = [list(port), [port[0], trunk_v], [tgt[0], trunk_v], tgt] + else: + edges[i]["points"] = [list(port), [trunk_v, port[1]], [trunk_v, tgt[1]], tgt] + else: # fan_in + srcp = spoke_pt + if vertical: + edges[i]["points"] = [srcp, [srcp[0], trunk_v], [port[0], trunk_v], list(port)] + else: + edges[i]["points"] = [srcp, [trunk_v, srcp[1]], [trunk_v, port[1]], list(port)] + # Lock the trunk: downstream optimizers must not move the shared + # coordinate. The spoke (3rd point toward the individual end) stays + # free to be nudged if needed. + edges[i]["_fan_locked"] = { + "mode": mode, + "axis": "y" if vertical else "x", + "trunk": trunk_v, + "port": list(port), + } diff --git a/skill/sdpm/layout/routing.py b/skill/sdpm/layout/routing.py new file mode 100644 index 00000000..94d193ae --- /dev/null +++ b/skill/sdpm/layout/routing.py @@ -0,0 +1,1141 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +"""Edge routing: orthogonal connection paths plus the port/group-bus/fan +alignment passes that run directly on freshly routed edges. +""" + +from .geometry import ( + _count_all_crossings, + _count_backwards, + _count_group_pierces, + _count_node_pierces, + _find_first_crossing, + _segments_intersect, +) +from .model import ( + _auto_sides, + _detour_path, + _elbow_path, + _find_endpoint, + _find_group, + _find_group_for, + _find_node, + _group_member_ids, + _port_point, +) +from .refine import ( + _MAX_RESOLVE_ITERATIONS, + _W_GROUP_PIERCE_ENGINE, + _defect_weight, + _detour_around_pierces, + _edge_pierces, + _optimize_bends, + _reselect_sides, + _resolve_crossing_search, + _rewrite_fan, + _separate_close_bends, +) + + + + +def _layout_route_connections(connections, nodes, groups=None): + """Route connections between nodes. Returns list of edge dicts with points.""" + groups = groups or {} + # Build node-to-group mapping and obstacle list + node_group = {} + for gid, g in groups.items(): + for cid in g.get("children", []): + node_group[cid] = gid + obstacles = [{"x": g["x"], "y": g["y"], "width": g["width"], "height": g["height"]} for g in groups.values()] + # Also add all nodes as obstacles so arrows avoid passing through icons + for nid, n in nodes.items(): + obstacles.append({"x": n["x"], "y": n["y"], "width": n.get("width", 60), "height": n.get("height", 60), "_node": nid}) + + port_counts = {} + port_indices = {} + + # First pass: identify reverse-flow connections (they use bottom ports, not side ports) + # Skip if explicit side hints are provided (graph layout mode). + # Only treat as reverse if the horizontal displacement is dominant (not a vertical connection). + reverse_set = set() + for i, conn in enumerate(connections): + if conn.get("srcSide") or conn.get("dstSide"): + continue + src = _find_node(nodes, conn["from"]) + dst = _find_node(nodes, conn["to"]) + if src and dst and dst["x"] + dst["width"] < src["x"]: + src_cy = src["y"] + src.get("height", 60) / 2 + dst_cy = dst["y"] + dst.get("height", 60) / 2 + dx = src["x"] - (dst["x"] + dst["width"]) + dy = abs(dst_cy - src_cy) + # Only treat as a reverse-flow (U-shaped detour) when the target is + # to the left AND roughly on the same row — a genuine feedback loop. + # If the target is also well above/below, a normal elbow routes it + # cleanly; the U-detour would wrap awkwardly into the wrong edge. + if dx > dy * 2: + reverse_set.add(i) + + # Track decided sides per source node to ensure consistency for fan-out + decided_src_side = {} + + # Identify fan-out sources (nodes with multiple forward targets). + # For fan-out nodes, pre-compute the best exit side by majority vote + # of what _auto_sides would choose, preferring horizontal ("right"/"left"). + _src_target_count: dict = {} + _src_side_votes: dict = {} # {src_id: {"right": n, "left": n, ...}} + for idx, conn in enumerate(connections): + if idx in reverse_set: + continue + if conn.get("srcSide") or conn.get("dstSide"): + continue + sid = conn["from"] + _src_target_count[sid] = _src_target_count.get(sid, 0) + 1 + src = _find_node(nodes, conn["from"]) + dst = _find_node(nodes, conn["to"]) + if src and dst: + s_side, _ = _auto_sides(src, dst, None) + _src_side_votes.setdefault(sid, {}) + _src_side_votes[sid][s_side] = _src_side_votes[sid].get(s_side, 0) + 1 + fanout_sources = {sid for sid, cnt in _src_target_count.items() if cnt >= 2} + + # Pre-decide a shared exit side for a fan-out source ONLY when a strict + # majority of its targets naturally want the same side. Forcing one side + # when targets are scattered (e.g. one to the right, one below-left) makes + # the minority arrows exit backwards through the source icon. When there is + # no majority, leave the source un-decided so each edge keeps its natural + # side. Among ties, prefer horizontal ("right" > "left") for left→right flow. + for sid in fanout_sources: + votes = _src_side_votes.get(sid, {}) + if not votes: + continue + total = sum(votes.values()) + # candidate = the side with the most votes (horizontal preferred on tie) + ordered = sorted(votes.items(), key=lambda kv: (-kv[1], {"right": 0, "left": 1, "bottom": 2, "top": 3}.get(kv[0], 9))) + best_side, best_n = ordered[0] + if best_n > total / 2: + decided_src_side[sid] = best_side + + conn_sides = [] + for i, conn in enumerate(connections): + src, _src_is_grp = _find_endpoint(nodes, groups, conn["from"]) + dst, _dst_is_grp = _find_endpoint(nodes, groups, conn["to"]) + if not src or not dst: + conn_sides.append((None, None, None, None)) + continue + if i in reverse_set: + conn_sides.append((src, dst, "bottom", "bottom")) + continue + + # Allow explicit side hints from connection spec + explicit_src = conn.get("srcSide") + explicit_dst = conn.get("dstSide") + + group_dir = None + src_gid = _find_group_for(conn["from"], node_group) + dst_gid = _find_group_for(conn["to"], node_group) + if src_gid and src_gid == dst_gid: + group_dir = groups[src_gid].get("direction", "horizontal") + src_side, dst_side = _auto_sides(src, dst, group_dir) + + if explicit_src: + src_side = explicit_src + if explicit_dst: + dst_side = explicit_dst + + # Consistency: if this source already has a decided side for forward connections, + # reuse it to prevent some arrows exiting from a different side (e.g. bottom). + # Skip this override when explicit sides are provided, or when the decided side + # is perpendicular to the natural direction (would create a bad route). + # Exception: for fan-out sources (1 source → N targets), ALWAYS apply the + # decided side to keep all arrows exiting from the same edge. + src_id = conn["from"] + if not explicit_src and not explicit_dst: + if src_id in decided_src_side: + decided = decided_src_side[src_id] + # For fan-out sources, always use the decided side. + # For non-fan-out, only apply if same axis (horizontal↔horizontal + # or vertical↔vertical) to avoid bad routes. + h_sides = {"left", "right"} + natural_axis = "h" if src_side in h_sides else "v" + decided_axis = "h" if decided in h_sides else "v" + # Never apply the decided side if the target lies on the OPPOSITE + # side, which would make the arrow exit backwards through the + # source icon (e.g. forcing "right" when the target is to the + # left). Check the target's actual direction relative to source. + decided_is_backwards = False + # Whether the decided side's axis matches the target's DOMINANT + # direction. Forcing a vertical (top/bottom) exit toward a target + # that is primarily to the side (or vice-versa) makes the arrow + # wrap awkwardly around the target — so only force when the axes + # agree, even for fan-out sources. + decided_axis_matches_target = True + if src and dst: + s_cx = src["x"] + src.get("width", 60) / 2 + s_cy = src["y"] + src.get("height", 60) / 2 + d_cx = dst["x"] + dst.get("width", 60) / 2 + d_cy = dst["y"] + dst.get("height", 60) / 2 + if decided == "right" and d_cx < s_cx: + decided_is_backwards = True + elif decided == "left" and d_cx > s_cx: + decided_is_backwards = True + elif decided == "bottom" and d_cy < s_cy: + decided_is_backwards = True + elif decided == "top" and d_cy > s_cy: + decided_is_backwards = True + adx = abs(d_cx - s_cx) + ady = abs(d_cy - s_cy) + target_axis = "h" if adx >= ady else "v" + decided_axis_matches_target = (target_axis == decided_axis) + apply_decided = ( + (not decided_is_backwards) + and decided_axis_matches_target + and ((src_id in fanout_sources) or (natural_axis == decided_axis)) + ) + if apply_decided: + src_side = decided + # Fix dst_side for fan-out: use opposite side, but if dst + # is directly above/below src (not to the side), keep natural dst_side + if src_id in fanout_sources: + if src and dst: + src_cx = src["x"] + src.get("width", 60) / 2 + dst_cx = dst["x"] + dst.get("width", 60) / 2 + src_cy = src["y"] + src.get("height", 60) / 2 + dst_cy = dst["y"] + dst.get("height", 60) / 2 + adx = abs(dst_cx - src_cx) + ady = abs(dst_cy - src_cy) + if ady > adx * 2: + # Target is mostly above/below — use natural sides + natural_src, natural_dst = _auto_sides(src, dst, None) + src_side = natural_src + dst_side = natural_dst + else: + # Target is to the side — use opposite + if src_side == "right": + dst_side = "left" + elif src_side == "left": + dst_side = "right" + elif src_side == "bottom": + dst_side = "top" + elif src_side == "top": + dst_side = "bottom" + else: + if src_side == "right": + dst_side = "left" + elif src_side == "left": + dst_side = "right" + elif src_side == "bottom": + dst_side = "top" + elif src_side == "top": + dst_side = "bottom" + else: + if src_side == "right" and dst_side == "top": + dst_side = "left" + elif src_side == "left" and dst_side == "bottom": + dst_side = "right" + elif src_side == "bottom" and dst_side == "right": + dst_side = "top" + elif src_side == "top" and dst_side == "left": + dst_side = "bottom" + else: + decided_src_side[src_id] = src_side + + conn_sides.append((src, dst, src_side, dst_side)) + sk = (conn["from"], src_side) + dk = (conn["to"], dst_side) + port_counts[sk] = port_counts.get(sk, 0) + 1 + port_counts[dk] = port_counts.get(dk, 0) + 1 + + # Optimize port assignment order to minimize crossings. + # Group connections by (node, side), then try permutations of port order. + # Exclude reverse connections (they use dedicated bottom ports). + port_groups = {} + for i, (src, dst, src_side, dst_side) in enumerate(conn_sides): + if src is None or i in reverse_set: + continue + sk = (connections[i]["from"], src_side) + dk = (connections[i]["to"], dst_side) + port_groups.setdefault(sk, []).append(i) + port_groups.setdefault(dk, []).append(i) + + port_indices = _optimize_port_order(port_groups, conn_sides, connections, nodes, port_counts, obstacles) + + # Compute global bounding box for detour routing + all_y = [] + for n in nodes.values(): + all_y.append(n["y"]) + all_y.append(n["y"] + n["height"]) + global_bottom = max(all_y) + 60 if all_y else 500 + + # Compute fan-out shared bend X for right-side fan-outs. + # All connections from a fan-out source share the same bend X (midpoint to nearest target). + fanout_bend_x = {} + for sid in fanout_sources: + if decided_src_side.get(sid) == "right": + src_node = _find_node(nodes, sid) + if not src_node: + continue + src_right = src_node["x"] + src_node["width"] + # Find nearest target left edge + target_lefts = [] + for idx, conn in enumerate(connections): + if conn["from"] == sid and idx not in reverse_set: + dst_node = _find_node(nodes, conn["to"]) + if dst_node: + target_lefts.append(dst_node["x"]) + # Only consider targets to the right of source + right_targets = [x for x in target_lefts if x > src_right] + if right_targets: + nearest_left = min(right_targets) + fanout_bend_x[sid] = src_right + (nearest_left - src_right) * 0.45 + elif target_lefts: + # All targets are to the left or same X — use a fixed offset + fanout_bend_x[sid] = src_right + 30 + + edges = [] + for i, conn in enumerate(connections): + src, dst, src_side, dst_side = conn_sides[i] + if src is None: + edges.append({"from": conn["from"], "to": conn["to"], "label": conn.get("label", ""), "points": []}) + continue + + # Group endpoints: the port sits on the group's box edge (no label + # offset), and the line is allowed to enter the box — so the group's + # own member icons are excluded from this edge's obstacles. + src_is_grp = _find_node(nodes, conn["from"]) is None + dst_is_grp = _find_node(nodes, conn["to"]) is None + + if i in reverse_set: + src_node = _find_node(nodes, conn["from"]) + dst_node = _find_node(nodes, conn["to"]) + label_h_src = 30 if src_node.get("label") else 0 + label_h_dst = 30 if dst_node.get("label") else 0 + sp = _port_point(src_node, "bottom", 0, 1, label_h_src) + tp = _port_point(dst_node, "bottom", 0, 1, label_h_dst) + points = _detour_path(sp, tp, "bottom", "bottom", global_bottom) + else: + # A group port uses no label height; a node port offsets the bottom + # edge by the label band. + src_label_h = 0 if src_is_grp else (30 if src.get("label") else 0) + dst_label_h = 0 if dst_is_grp else (30 if dst.get("label") else 0) + sp = _port_point(src, src_side, port_indices[(i, conn["from"])], port_counts[(conn["from"], src_side)], src_label_h) + tp = _port_point(dst, dst_side, port_indices[(i, conn["to"])], port_counts[(conn["to"], dst_side)], dst_label_h) + + # Route every edge with the standard elbow path. The downstream + # bend optimizer, side reselection, and detour passes shape each + # edge to minimize crossings/pierces — a shared fan-out trunk is + # no longer special-cased because, when targets sit on a row with + # an obstacle between them, a fixed trunk grazes that obstacle and + # no trunk position can avoid it (only a detour can). + excl = {conn["from"], conn["to"]} + # Connecting to/from a group means the line may pass into that + # group's box — exclude the group's member icons (and the group + # box itself) from this edge's obstacles. + if src_is_grp: + excl |= _group_member_ids(nodes, groups, conn["from"]) + if dst_is_grp: + excl |= _group_member_ids(nodes, groups, conn["to"]) + conn_obs = [o for o in obstacles if o.get("_node") not in excl] + points = _elbow_path(sp, tp, src_side, dst_side, conn_obs) + edge_entry = {"from": conn["from"], "to": conn["to"], "label": conn.get("label", ""), "points": points} + if src_is_grp: + edge_entry["_src_group"] = conn["from"] + if dst_is_grp: + edge_entry["_dst_group"] = conn["to"] + edges.append(edge_entry) + + # T8: Merge fan-out/fan-in groups onto a unified port + shared trunk when + # "fan": "merge" is set. This is treated as a HARD CONSTRAINT: the merged + # edges are tagged `_fan_locked` and every downstream pass leaves their + # trunks untouched. Crossing avoidance for merged fans comes from placement + # (the order/direction search) and from routing the OTHER edges around the + # fixed trunks — not from un-merging them. + _align_fan_bends(edges, conn_sides, connections, nodes, groups) + + # T9: Safe bend separation — shift overlapping vertical bends apart + # while preserving axis-alignment (only move X of vertical segments, + # never touch start/end points). Skips locked fan trunks. + _safe_separate_bends(edges) + + # T10: Bend optimization — slide each free (middle) bend of an elbow path + # along its axis to minimize a global cost (crossings + weighted icon + # pierces). The free bend of a 4-point VHV/HVH path can move without + # touching the port-anchored endpoints, so axis-alignment and + # perpendicularity are preserved. This is the primary crossing/pierce + # reducer; it never introduces diagonals or backwards segments. + _optimize_bends(edges, nodes) + + # T11: Side/port reselection — a remaining pierce is usually a poor choice + # of which icon edge the arrow attaches to. Re-route each still-piercing + # edge through the elbow router with an alternative (src_side, dst_side), + # keeping it only if it lowers pierces without raising crossings. Adds no + # segments (unlike a detour), so it cannot cause a crossing blow-up, and + # endpoints stay perpendicular because every port comes from _port_point. + obstacles_re = [o for o in obstacles if o.get("_node")] + # Guard the whole reselect pass on the global weighted defect score: the + # per-edge slack (allowing +1 crossing to clear a pierce) is locally sound + # but can accumulate across edges into a net-worse layout. Roll back if the + # weighted total (crossings + 1.5*pierces + 0.7*backwards) regresses. + _resel_before = _defect_weight((_count_all_crossings(edges), + _count_node_pierces(edges, nodes), + _count_backwards(edges, nodes))) + _resel_snapshot = [list(map(list, e["points"])) for e in edges] + _reselect_sides(edges, nodes, obstacles_re) + _resel_after = _defect_weight((_count_all_crossings(edges), + _count_node_pierces(edges, nodes), + _count_backwards(edges, nodes))) + if _resel_after > _resel_before: + for e, pts in zip(edges, _resel_snapshot): + e["points"] = pts + _optimize_bends(edges, nodes) + + # T12: Obstacle detour — for pierces that no side/port choice can clear + # (e.g. an icon stacked directly between source and target in the same + # column), splice an axis-aligned jog around the obstacle. Each candidate + # is judged by the weighted defect score (pierce 1.5 > cross 1.0), so a jog + # may add a crossing to lift a line off an icon it cuts through. The jog is + # spliced into the interior of a segment, so endpoints never move and no + # diagonal is ever produced. Guarded on the global weighted total so the + # per-edge slack can't accumulate into a net-worse layout. + _det_before = _defect_weight((_count_all_crossings(edges), + _count_node_pierces(edges, nodes) + + _count_group_pierces(edges, groups, nodes), + _count_backwards(edges, nodes))) + _det_snapshot = [list(map(list, e["points"])) for e in edges] + _detour_around_pierces(edges, nodes, groups) + _det_after = _defect_weight((_count_all_crossings(edges), + _count_node_pierces(edges, nodes) + + _count_group_pierces(edges, groups, nodes), + _count_backwards(edges, nodes))) + if _det_after > _det_before: + for e, pts in zip(edges, _det_snapshot): + e["points"] = pts + + # T13: Port recentering — by now each edge's actual entry/exit side may + # differ from the side that seeded port_counts (fan merge, side reselect, + # and elbow re-picks all move endpoints). That stale count left, e.g., a + # lone right-side edge sharing a "2 ports" slot and sitting off-center. + # Recompute the real per-(node, side) usage from geometry and redistribute + # the ports evenly along each edge, snapping the adjacent bend so the stub + # stays perpendicular. Fan-locked endpoints are fixed and excluded. + # Guarded per (node, side) group: each redistribution is kept only if it + # does not increase global crossings — centering a port can occasionally + # re-introduce a crossing that bend-opt had removed. + _recenter_ports(edges, nodes) + + # T14: Group bus bundling — when several edges share a GROUP endpoint + # (many-to-one to a box), bundle them so they enter/leave the box edge as a + # tidy parallel bus instead of fanning to scattered points. Nested lanes + # ordered by the opposite end keep the bundle crossing-free. Guarded on the + # global crossing count. + _align_group_bus(edges, nodes, groups) + + # T15: Straighten solo group-endpoint edges. A connection to a GROUP box + # defaults its port to the box center, so a node→tall-group (or small-group→ + # tall-group) edge bends into an L even when a straight run fits inside both + # facing edges — the "Step Functions → Processing" / "Processing → Shared" + # L-bends. Slide the port that has the larger box to the smaller endpoint's + # center so the arrow becomes a clean straight line. Guarded; bundles owned + # by the group bus (T14) are left alone. + _straighten_group_edges(edges, nodes, groups) + + # T16: U-turn a group-endpoint monitor edge around framed boxes. An edge + # from a GROUP box to a far node with framed groups in between (e.g. the ETL + # group → CloudWatch across the Consumers frame) defaults to a right-exit + # that the detour pass can only hack past, leaving a staircase that still + # grazes the frame. This one-shot pass tries a single clean U: exit the + # group's BOTTOM (or TOP), run a trunk just past the obstacle boxes, and + # enter the far node on the matching face. Committed only if it lowers the + # weighted defect total. Cheap: one candidate per qualifying edge. + _uturn_group_endpoint_edges(edges, nodes, groups) + + return edges + + +_UTURN_CLEAR = 20 + + +def _uturn_group_endpoint_edges(edges, nodes, groups): + """Reroute a solo group-endpoint edge that cuts framed boxes into a clean U. + + Targets an edge with a GROUP endpoint that still pierces one or more framed + group boxes (a monitor/aggregator line crossing the diagram). Builds ONE + candidate per vertical direction: leave the group box's bottom (or top) edge, + run a horizontal trunk just beyond ALL the boxes it would otherwise cross, + then rise/drop into the far endpoint on that same vertical face. Keeps the + candidate only if the global weighted defect total strictly improves and + crossings do not rise. O(edges × groups) — no port/side search. + """ + if not groups: + return edges + framed = [g for g in groups.values() if g.get("groupType")] + if not framed: + return edges + + def weighted(): + return _defect_weight((_count_all_crossings(edges), + _count_node_pierces(edges, nodes) + + _W_GROUP_PIERCE_ENGINE * _count_group_pierces(edges, groups, nodes), + _count_backwards(edges, nodes))) + + for e in edges: + if e.get("_fan_locked") or e.get("_fanout"): + continue + if len(e["points"]) < 2: + continue + # Must involve a group endpoint and still have a routing defect (a + # framed-box cut OR a non-endpoint icon pierce) the prior passes left — + # the staircase the detour produced still grazes icons/frames. + if not (e.get("_src_group") or e.get("_dst_group")): + continue + if (_count_group_pierces([e], groups, nodes) == 0 + and not _edge_pierces(e, nodes)): + continue + src, src_is_grp = _find_endpoint(nodes, groups, e["from"]) + dst, dst_is_grp = _find_endpoint(nodes, groups, e["to"]) + if not src or not dst: + continue + + # Boxes this edge must clear (framed, not its own endpoints/members). + efrom, eto = e["from"].rsplit(".", 1)[-1], e["to"].rsplit(".", 1)[-1] + boxes = [] + for gid, g in groups.items(): + if not g.get("groupType"): + continue + gshort = gid.rsplit(".", 1)[-1] + if gshort in (efrom, eto): + continue + members = _group_member_ids(nodes, groups, gid) + if efrom in members or eto in members: + continue + boxes.append(g) + if not boxes: + continue + + s_label = 0 if src_is_grp else (30 if src.get("label") else 0) + d_label = 0 if dst_is_grp else (30 if dst.get("label") else 0) + sx_c = src["x"] + src["width"] // 2 + dx_c = dst["x"] + dst["width"] // 2 + snap = [list(map(list, ee["points"])) for ee in edges] + before = weighted() + before_cross = _count_all_crossings(edges) + + best = None + for vside in ("bottom", "top"): + # Trunk Y just past every box on the chosen vertical side, and past + # both endpoints' own extents so the stubs don't clip their boxes. + if vside == "bottom": + trunk_y = max([g["y"] + g["height"] for g in boxes] + + [src["y"] + src["height"], dst["y"] + dst["height"]]) + _UTURN_CLEAR + sp = [sx_c, src["y"] + src["height"] + s_label] + tp = [dx_c, dst["y"] + dst["height"] + d_label] + else: + trunk_y = min([g["y"] for g in boxes] + + [src["y"], dst["y"]]) - _UTURN_CLEAR + sp = [sx_c, src["y"]] + tp = [dx_c, dst["y"]] + cand = [sp, [sp[0], trunk_y], [tp[0], trunk_y], tp] + e["points"] = cand + w = weighted() + if (w < before and _count_all_crossings(edges) <= before_cross + and (best is None or w < best[0])): + best = (w, [list(p) for p in cand]) + for ee, pts in zip(edges, snap): + ee["points"] = pts + + if best is not None: + e["points"] = best[1] + return edges + + +def _straighten_group_edges(edges, nodes, groups): + """Make a solo group-endpoint edge a straight line when one fits. + + A connection whose endpoint is a GROUP box gets its port at the box center, + so when the two endpoints differ in cross-axis extent (a 60px icon vs a + 765px column, or two columns of unequal height) the elbow router bends the + edge even though a single straight segment would fit inside both facing + edges. For each such edge whose two ports sit on facing horizontal (or + facing vertical) sides, we choose a common cross-axis coordinate that lies + inside BOTH endpoints' spans — preferring the SMALLER endpoint's center, so + single icons and small boxes attach at their visual middle and the larger + box absorbs the offset — and re-emit a straight 2-point edge. + + Left untouched: fan trunks (the merge is a hard constraint) and any edge + sharing a group endpoint with another edge (a many-to-one bundle owned by + the group-bus pass, T14). Guarded on the global weighted defect total so + straightening can never add a crossing, icon pierce, or frame pierce. + """ + if not edges: + return edges + + # Count edges per group endpoint so many-to-one bundles stay with the bus. + grp_use = {} + for e in edges: + if e.get("_src_group"): + grp_use[("src", e["_src_group"])] = grp_use.get(("src", e["_src_group"]), 0) + 1 + if e.get("_dst_group"): + grp_use[("dst", e["_dst_group"])] = grp_use.get(("dst", e["_dst_group"]), 0) + 1 + + def weighted(es): + return _defect_weight((_count_all_crossings(es), + _count_node_pierces(es, nodes) + + _count_group_pierces(es, groups, nodes), + _count_backwards(es, nodes))) + + for e in edges: + if e.get("_fan_locked") or e.get("_fanout"): + continue + pts = e["points"] + if len(pts) < 2: + continue + # Only group-endpoint edges suffer the box-center kink; node→node edges + # are already snapped straight by the elbow router when they line up. + if not (e.get("_src_group") or e.get("_dst_group")): + continue + if e.get("_src_group") and grp_use.get(("src", e["_src_group"]), 0) >= 2: + continue + if e.get("_dst_group") and grp_use.get(("dst", e["_dst_group"]), 0) >= 2: + continue + s_geom, _ = _find_endpoint(nodes, groups, e["from"]) + d_geom, _ = _find_endpoint(nodes, groups, e["to"]) + if not s_geom or not d_geom: + continue + first_h = abs(pts[0][1] - pts[1][1]) <= 2 + first_v = abs(pts[0][0] - pts[1][0]) <= 2 + last_h = abs(pts[-1][1] - pts[-2][1]) <= 2 + last_v = abs(pts[-1][0] - pts[-2][0]) <= 2 + + new_pts = None + if first_h and last_h and abs(pts[0][1] - pts[-1][1]) > 2: + # Both ports on left/right edges → straighten on a common Y. + lo = max(s_geom["y"], d_geom["y"]) + hi = min(s_geom["y"] + s_geom["height"], d_geom["y"] + d_geom["height"]) + if hi - lo > 2: + if s_geom["height"] <= d_geom["height"]: + c = s_geom["y"] + s_geom["height"] / 2 + else: + c = d_geom["y"] + d_geom["height"] / 2 + y = round(min(max(c, lo + 1), hi - 1)) + new_pts = [[pts[0][0], y], [pts[-1][0], y]] + elif first_v and last_v and abs(pts[0][0] - pts[-1][0]) > 2: + # Both ports on top/bottom edges → straighten on a common X. + lo = max(s_geom["x"], d_geom["x"]) + hi = min(s_geom["x"] + s_geom["width"], d_geom["x"] + d_geom["width"]) + if hi - lo > 2: + if s_geom["width"] <= d_geom["width"]: + c = s_geom["x"] + s_geom["width"] / 2 + else: + c = d_geom["x"] + d_geom["width"] / 2 + x = round(min(max(c, lo + 1), hi - 1)) + new_pts = [[x, pts[0][1]], [x, pts[-1][1]]] + if new_pts is None: + continue + + snap = [list(map(list, ee["points"])) for ee in edges] + before = weighted(edges) + e["points"] = new_pts + if weighted(edges) > before: + for ee, p in zip(edges, snap): + ee["points"] = p + return edges + + +_PORT_EPS = 4 +_GROUP_BUS_PORT_GAP = 26 +_GROUP_BUS_LANE_GAP = 14 + + +def _align_group_bus(edges, nodes, groups): + """Bundle edges that share a group endpoint into a parallel bus on the box. + + For each group that is the target (or source) of 2+ edges, route those + edges so their final (or first) approach runs as nested parallel lanes into + adjacent ports centered on the box edge facing the other ends. Ordering the + lanes by the opposite end's position keeps the bundle free of self-cross. + Kept only if it does not raise the global crossing count. + """ + if not groups: + return edges + + # Collect bundles: (group_id, role) -> list of edges, where role is 'dst' + # (edges ending at the group) or 'src' (edges starting at the group). + bundles = {} + for e in edges: + if len(e["points"]) < 2: + continue + # A fan-merged group edge is already a deliberate single-trunk bundle; + # leave it to the fan layout, don't re-bundle it here. + if e.get("_fan_locked"): + continue + if e.get("_dst_group"): + bundles.setdefault((e["_dst_group"], "dst"), []).append(e) + if e.get("_src_group"): + bundles.setdefault((e["_src_group"], "src"), []).append(e) + + for (gid, role), all_grp_edges in bundles.items(): + if len(all_grp_edges) < 2: + continue + g = _find_group(groups, gid) + if not g: + continue + gx, gy, gw, gh = g["x"], g["y"], g["width"], g["height"] + bcx, bcy = gx + gw / 2, gy + gh / 2 + + # The "free end" of each edge is the non-group end. + def free_pt(e): + return e["points"][0] if role == "dst" else e["points"][-1] + + # Assign each edge to the box side its OWN free end faces (not the + # bundle centroid). A group can radiate in several directions at once — + # e.g. Stream Processing → Firehose (right), → OpenSearch (above), + # → CloudWatch (below). Bundling all three onto one centroid side drags + # the up/down edges out the right face and makes them detour. Only edges + # that genuinely share a side should share a bus. + by_side = {} + for e in all_grp_edges: + fp = free_pt(e) + dx, dy = fp[0] - bcx, fp[1] - bcy + if abs(dx) >= abs(dy): + e_side = "left" if dx < 0 else "right" + else: + e_side = "top" if dy < 0 else "bottom" + by_side.setdefault(e_side, []).append(e) + + for side, grp_edges in by_side.items(): + # A single edge on a side keeps its natural port — nothing to bundle. + if len(grp_edges) < 2: + continue + vertical_ports = side in ("left", "right") # ports vary along Y + + snapshot = [list(map(list, e["points"])) for e in edges] + before = _count_all_crossings(edges) + + # Order edges by their free end's coordinate along the port axis so + # adjacent ports connect to adjacent sources (no self-cross). + grp_edges.sort(key=lambda e: free_pt(e)[1] if vertical_ports else free_pt(e)[0]) + n = len(grp_edges) + # Box-edge anchor coordinates (the fixed coordinate of the port line). + bx = gx if side == "left" else (gx + gw) # used when vertical_ports + by = gy if side == "top" else (gy + gh) # used otherwise + + for rank, e in enumerate(grp_edges): + off = (rank - (n - 1) / 2) * _GROUP_BUS_PORT_GAP + fp = free_pt(e) + # Nested lane: outer (farther from center) edges turn earlier so + # the bundle telescopes without crossing. + lane_depth = (n - rank) * _GROUP_BUS_LANE_GAP if role == "dst" else (rank + 1) * _GROUP_BUS_LANE_GAP + if vertical_ports: + py = round(bcy + off) + # Outer lanes turn farther from the box so it telescopes. + lane = (gx - 20 - lane_depth) if side == "left" else (gx + gw + 20 + lane_depth) + port = [bx, py] + if role == "dst": + e["points"] = [fp, [lane, fp[1]], [lane, py], port] + else: + e["points"] = [port, [lane, py], [lane, fp[1]], fp] + else: + px = round(bcx + off) + lane = (gy - 20 - lane_depth) if side == "top" else (gy + gh + 20 + lane_depth) + port = [px, by] + if role == "dst": + e["points"] = [fp, [fp[0], lane], [px, lane], port] + else: + e["points"] = [port, [px, lane], [fp[0], lane], fp] + + if _count_all_crossings(edges) > before: + for e, pts in zip(edges, snapshot): + e["points"] = pts + + return edges + + +def _recenter_ports(edges, nodes): + """Evenly redistribute each node-edge's ports using the ACTUAL drawn sides. + + Endpoints are the only points moved (plus the immediately adjacent bend, to + keep the first/last stub axis-aligned). A single edge on a side lands dead + center; N edges split the side into N+1 even slots, ordered by the position + of their opposite end so they don't cross at the port. This corrects the + off-center stubs left by stale port_counts after fan/side changes. + """ + def side_of(node, pt): + x, y = pt + nx, ny = node["x"], node["y"] + w = node.get("width", 60) + h = node.get("height", w) + if nx - _PORT_EPS <= x <= nx + w + _PORT_EPS: + if y >= ny + h - _PORT_EPS: + return "bottom" + if y <= ny + _PORT_EPS: + return "top" + if ny - _PORT_EPS <= y <= ny + h + _PORT_EPS: + if x <= nx + _PORT_EPS: + return "left" + if x >= nx + w - _PORT_EPS: + return "right" + return None + + # Gather endpoints to move: (node, side) -> list of (edge, end_index, opp_pt) + groups = {} + for e in edges: + pts = e["points"] + if len(pts) < 2 or e.get("_fanout"): + continue + for end_idx, nid in ((0, e["from"]), (-1, e["to"])): + # Skip the locked end of a fan edge (its port is the shared trunk port). + if e.get("_fan_locked"): + lock = e["_fan_locked"] + # fan_out: shared port at start; fan_in: shared port at end. + if (lock["mode"] == "fan_out" and end_idx == 0) or \ + (lock["mode"] == "fan_in" and end_idx == -1): + continue + node = _find_node(nodes, nid) + if node is None: + continue + s = side_of(node, pts[end_idx]) + if s is None: + continue + opp = pts[-1] if end_idx == 0 else pts[0] + groups.setdefault((nid, s), []).append((e, end_idx, opp, node)) + + for (nid, side), members in groups.items(): + node = members[0][3] + nx, ny = node["x"], node["y"] + w = node.get("width", 60) + h = node.get("height", w) + label_h = 30 if node.get("label") else 0 + n = len(members) + # Order members along the edge by the coordinate of their opposite end, + # so adjacent ports connect to adjacent targets (minimizes self-cross). + if side in ("left", "right"): + members.sort(key=lambda m: m[2][1]) # by opposite Y + else: + members.sort(key=lambda m: m[2][0]) # by opposite X + + # Snapshot the edges this group touches so we can roll back if centering + # the ports happens to add a crossing the optimizer had removed. + touched = {id(e): list(map(list, e["points"])) for e, _, _, _ in members} + before = _count_all_crossings(edges) + + for slot, (e, end_idx, opp, _node) in enumerate(members): + t = (slot + 1) / (n + 1) + pts = e["points"] + if side == "right": + newp = [nx + w, round(ny + h * t)] + elif side == "left": + newp = [nx, round(ny + h * t)] + elif side == "bottom": + newp = [round(nx + w * t), ny + h + label_h] + else: # top + newp = [round(nx + w * t), ny] + # Move the endpoint and snap the adjacent bend to keep the stub + # perpendicular: for a left/right port the stub is horizontal, so + # the neighbor shares the new Y; for top/bottom it shares the new X. + adj_idx = 1 if end_idx == 0 else len(pts) - 2 + if 0 <= adj_idx < len(pts): + if side in ("left", "right"): + pts[adj_idx] = [pts[adj_idx][0], newp[1]] + else: + pts[adj_idx] = [newp[0], pts[adj_idx][1]] + pts[end_idx] = newp + + if _count_all_crossings(edges) > before: + for e, _, _, _ in members: + e["points"] = touched[id(e)] + + +def _safe_separate_bends(edges): + """Separate overlapping vertical bends by shifting their X position. + + Rules: + - Only shifts X of vertical segments (never Y of horizontal segments) + - Never moves pts[0] or pts[-1] (port-anchored) + - When shifting a vertical segment's X, also updates the adjacent horizontal + segments' endpoints to maintain connectivity + - Minimum separation: 30px between parallel vertical bends + """ + MIN_SEP = 30 + + # Collect vertical segments: (edge_idx, seg_start_idx, x, y_lo, y_hi) + v_segs = [] + for ei, e in enumerate(edges): + pts = e["points"] + if e.get("_fanout") or e.get("_fan_locked"): + continue + for k in range(len(pts) - 1): + if abs(pts[k][0] - pts[k+1][0]) <= 3 and abs(pts[k][1] - pts[k+1][1]) > 10: + # Vertical segment, not touching start/end + if k == 0 or k == len(pts) - 2: + continue + x = pts[k][0] + y_lo = min(pts[k][1], pts[k+1][1]) + y_hi = max(pts[k][1], pts[k+1][1]) + v_segs.append((ei, k, x, y_lo, y_hi)) + + # Group vertical segments by similar X (within MIN_SEP) + v_segs.sort(key=lambda s: s[2]) + groups = [] + current_group = [] + for seg in v_segs: + if current_group and abs(seg[2] - current_group[0][2]) > MIN_SEP: + if len(current_group) >= 2: + groups.append(current_group) + current_group = [seg] + else: + current_group.append(seg) + if len(current_group) >= 2: + groups.append(current_group) + + # For each group, check if Y ranges overlap and spread X positions + for group in groups: + # Filter to segments with overlapping Y ranges + overlapping = [] + for i, seg in enumerate(group): + for other in group[i+1:]: + if seg[3] < other[4] and seg[4] > other[3]: + if seg not in overlapping: + overlapping.append(seg) + if other not in overlapping: + overlapping.append(other) + + if len(overlapping) < 2: + continue + + # Spread evenly around the center X + center_x = sum(s[2] for s in overlapping) / len(overlapping) + n = len(overlapping) + for i, (ei, k, old_x, y_lo, y_hi) in enumerate(sorted(overlapping, key=lambda s: s[3])): + new_x = round(center_x + (i - (n-1)/2) * MIN_SEP) + if new_x == old_x: + continue + pts = edges[ei]["points"] + # Shift the vertical segment + pts[k] = [new_x, pts[k][1]] + pts[k+1] = [new_x, pts[k+1][1]] + # Fix adjacent horizontal segments + if k > 0 and abs(pts[k-1][1] - pts[k][1]) <= 3: + pts[k-1] = [pts[k-1][0], pts[k-1][1]] # keep, connectivity maintained by polyline + if k+2 < len(pts) and abs(pts[k+1][1] - pts[k+2][1]) <= 3: + pass # horizontal after — connectivity OK since pts[k+1] x changed + + # Safety net: if any diagonal segments remain, insert L-shaped intermediates + for e in edges: + new_pts = [e["points"][0]] + pts = e["points"] + for k in range(1, len(pts)): + dx = abs(pts[k][0] - new_pts[-1][0]) + dy = abs(pts[k][1] - new_pts[-1][1]) + if dx > 3 and dy > 3: + new_pts.append([pts[k][0], new_pts[-1][1]]) + new_pts.append(pts[k]) + e["points"] = new_pts + + return edges + + +def _optimize_port_order(port_groups, conn_sides, connections, nodes, port_counts, obstacles): + """Find the port index assignment that minimizes edge crossings. + + For each (node, side) with multiple ports, try all permutations (≤6) + and pick the one with fewest crossings. For larger groups, use a + heuristic: sort ports by the Y (or X) coordinate of the peer endpoint. + """ + from itertools import permutations + + # Start with sequential assignment + port_indices = {} + port_cursors = {} + for i, (src, dst, src_side, dst_side) in enumerate(conn_sides): + if src is None: + continue + for nid, side in [(connections[i]["from"], src_side), (connections[i]["to"], dst_side)]: + k = (nid, side) + port_cursors[k] = port_cursors.get(k, 0) + port_indices[(i, nid)] = port_cursors[k] + port_cursors[k] += 1 + + # For each port group with ≥2 connections, optimize the order + for (nid, side), conn_indices in port_groups.items(): + if len(conn_indices) < 2: + continue + + count = port_counts[(nid, side)] + node = _find_node(nodes, nid) + if not node: + continue + + if len(conn_indices) <= 6: + # Brute force: try all permutations + best_crossings = None + best_assignment = None + + for perm in permutations(range(count)): + # Assign port indices according to this permutation + test_indices = dict(port_indices) + for slot, ci in enumerate(conn_indices): + test_indices[(ci, nid)] = perm[slot] + + crossings = _count_port_crossings(conn_indices, test_indices, conn_sides, connections, nodes, port_counts, obstacles) + if best_crossings is None or crossings < best_crossings: + best_crossings = crossings + best_assignment = perm + if crossings == 0: + break + + if best_assignment is not None: + for slot, ci in enumerate(conn_indices): + port_indices[(ci, nid)] = best_assignment[slot] + else: + # Heuristic: sort by peer endpoint coordinate + _heuristic_port_sort(conn_indices, nid, side, port_indices, conn_sides, connections, nodes) + + return port_indices + + +def _count_port_crossings(conn_indices, port_indices, conn_sides, connections, nodes, port_counts, obstacles): + """Count crossings among a set of edges sharing a port group.""" + # Generate paths for the relevant connections + paths = [] + for ci in conn_indices: + src, dst, src_side, dst_side = conn_sides[ci] + if src is None: + continue + label_h = 30 if src.get("label") else 0 + sp = _port_point(src, src_side, port_indices[(ci, connections[ci]["from"])], port_counts[(connections[ci]["from"], src_side)], label_h) + tp = _port_point(dst, dst_side, port_indices[(ci, connections[ci]["to"])], port_counts[(connections[ci]["to"], dst_side)], label_h) + path = _elbow_path(sp, tp, src_side, dst_side, obstacles) + paths.append(path) + + # Count pairwise segment crossings + crossings = 0 + for i in range(len(paths)): + for j in range(i + 1, len(paths)): + for si in range(len(paths[i]) - 1): + for sj in range(len(paths[j]) - 1): + if _segments_intersect(paths[i][si], paths[i][si + 1], paths[j][sj], paths[j][sj + 1]): + crossings += 1 + return crossings + + +def _heuristic_port_sort(conn_indices, nid, side, port_indices, conn_sides, connections, nodes): + """Sort ports by peer endpoint coordinate when brute force is too expensive.""" + peer_coords = [] + for ci in conn_indices: + src, dst, src_side, dst_side = conn_sides[ci] + if connections[ci]["from"] == nid: + peer = _find_node(nodes, connections[ci]["to"]) + coord = (peer["y"] + peer["height"] // 2) if peer else 0 + else: + peer = _find_node(nodes, connections[ci]["from"]) + coord = (peer["y"] + peer["height"] // 2) if peer else 0 + peer_coords.append((coord, ci)) + + # Sort by peer Y coordinate (or X for vertical sides) + if side in ("top", "bottom"): + for ci in conn_indices: + src, dst, src_side, dst_side = conn_sides[ci] + if connections[ci]["from"] == nid: + peer = _find_node(nodes, connections[ci]["to"]) + coord = (peer["x"] + peer["width"] // 2) if peer else 0 + else: + peer = _find_node(nodes, connections[ci]["from"]) + coord = (peer["x"] + peer["width"] // 2) if peer else 0 + peer_coords.append((coord, ci)) + peer_coords = peer_coords[len(conn_indices):] + + peer_coords.sort(key=lambda t: t[0]) + for slot, (_, ci) in enumerate(peer_coords): + port_indices[(ci, nid)] = slot + + +# Max spread between dst (or src) centers to allow grouping +_FAN_SPREAD_LIMIT = 600 + + +def _align_fan_bends(edges, conn_sides, connections, nodes=None, groups=None): + """Align bend positions and merge ports for fan-out and fan-in groups. + + Only activates when connections have "fan": "merge" set. A merged group is + a hard constraint: all edges sharing the same source (fan-out) or the same + target (fan-in) are forced onto ONE unified port and a shared trunk bend, + regardless of which icon edge the router originally chose. The side is + decided by majority vote across the group's edges so a single odd-side edge + no longer splinters the group (the previous (from, src_side) keying did). + + After this runs, each rewritten edge carries `_fan_locked` so downstream + optimizers (bend slide, side reselect, detour) leave its trunk alone — the + merge is the spec, and crossing reduction must work AROUND it, not undo it. + """ + def _apply_fan_guarded(indices, mode): + # The user wants same-purpose edges merged, so a merge that adds only a + # MODEST number of crossings is kept (a tidy trunk reads better than a + # few crossings). Roll back when: + # - the merged trunk PIERCES any icon. A fan bundle is `_fan_locked`, + # so the downstream pierce-resolution passes (side reselect, detour) + # CANNOT clear it later — whatever the locked trunk cuts through is + # permanent. Individually-routed edges, by contrast, get cleaned up + # by those passes, so an unmerged fan whose members pierce here may + # still reach 0 pierces in the final layout. Hence the test is + # "trunk pierces anything at all" (after_p > 0), not the weaker + # "merge ADDED pierces" — the latter compares two pre-optimization + # snapshots and wrongly keeps a doomed locked trunk (e.g. four + # agents fanning into a Bedrock hub through the icons below them). + # - the merge adds MORE crossings than the bundle size — a sign the + # trunk is fighting another structure (e.g. a hub that is both a + # fan-in and fan-out target), where separate routing is cleaner. + snap = {j: list(map(list, edges[j]["points"])) for j in indices} + before_c = _count_all_crossings(edges) + _rewrite_fan(edges, conn_sides, indices, mode=mode, nodes=nodes, groups=groups) + after_p = _count_node_pierces([edges[j] for j in indices], nodes) + after_c = _count_all_crossings(edges) + if after_p > 0 or (after_c - before_c) > len(indices): + for j in indices: + edges[j]["points"] = snap[j] + edges[j].pop("_fan_locked", None) + + # Fan-out: group purely by source node (side decided later by vote). + src_groups = {} + for i, (src, dst, src_side, dst_side) in enumerate(conn_sides): + if src is None or len(edges[i]["points"]) < 2: + continue + if connections[i].get("fan") != "merge": + continue + src_groups.setdefault(connections[i]["from"], []).append(i) + + for indices in src_groups.values(): + if len(indices) < 2: + continue + _apply_fan_guarded(indices, "fan_out") + + # Fan-in: group purely by target node. + dst_groups = {} + for i, (src, dst, src_side, dst_side) in enumerate(conn_sides): + if src is None or len(edges[i]["points"]) < 2: + continue + if connections[i].get("fan") != "merge": + continue + dst_groups.setdefault(connections[i]["to"], []).append(i) + + for indices in dst_groups.values(): + if len(indices) < 2: + continue + _apply_fan_guarded(indices, "fan_in") + + +def _spread_overlapping_bends(edges, conn_sides, connections): + """Iteratively resolve edge crossings and separate close bends. + + Phase 1: Resolve all crossings by searching for optimal bend shifts. + Phase 2: Separate bends that are too close (even if not crossing). + """ + # Phase 1: resolve crossings + for _iteration in range(_MAX_RESOLVE_ITERATIONS): + crossing = _find_first_crossing(edges) + if crossing is None: + break + i, si, j, sj = crossing + _resolve_crossing_search(edges, i, si, j, sj) + + # Phase 2: separate close parallel bends + _separate_close_bends(edges) From e6e7feb9b07d920e7f853e4b59224b7744adf8d4 Mon Sep 17 00:00:00 2001 From: ShotaroKataoka Date: Mon, 13 Jul 2026 16:26:13 +0900 Subject: [PATCH 79/79] test(layout): add seeded property-based tests for the routing engine Generates 12 random logical-structure trees (seeded random.Random, fully deterministic, no new test dependency) and asserts invariants that must hold for ANY input: - pipeline never crashes; elements/bbox/metrics well-formed - every leaf is placed with positive dimensions (dot-qualified ids) - every connection produces an edge with >= 2 points - measure() and render_architecture() metrics agree (entry-point drift and non-determinism guard) - score() is monotone in each defect class - _segments_cross is symmetric in segment order and endpoint order, and a segment never crosses itself (300 random segment pairs) - _count_all_crossings is invariant under edge-list order Pipeline results are memoized per seed to keep the suite fast (full suite ~6s). --- tests/test_layout_properties.py | 193 ++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 tests/test_layout_properties.py diff --git a/tests/test_layout_properties.py b/tests/test_layout_properties.py new file mode 100644 index 00000000..79d95268 --- /dev/null +++ b/tests/test_layout_properties.py @@ -0,0 +1,193 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +"""Property-based tests for the layout engine. + +Generates random logical-structure trees (seeded, deterministic) and checks +invariants that must hold for ANY input — the pipeline never crashes, geometry +is well-formed, metrics are internally consistent, and core geometric +predicates are symmetric. No new test dependency: a seeded random.Random +stands in for hypothesis. +""" + +from __future__ import annotations + +import random + +from sdpm.layout.geometry import _count_all_crossings, _segments_cross +from sdpm.layout.metrics import measure, score +from sdpm.layout.render import build_layout, render_architecture + +_ICONS = ["aws/lambda", "aws/s3", "aws/dynamodb", "aws/api-gateway", + "aws/sqs", "aws/fargate", "material/person", "aws/cloudfront"] +_GROUP_TYPES = ["vpc", "generic", "region"] + +_N_CASES = 12 + + +def _random_tree(rng: random.Random) -> dict: + """Build a random 1-2 level tree of 3-10 leaves with random connections.""" + n_leaves = rng.randint(3, 10) + leaf_ids = [f"n{i}" for i in range(n_leaves)] + + children: list[dict] = [] + pool = list(leaf_ids) + rng.shuffle(pool) + # Chance to wrap a run of leaves into a (possibly nested) group. + while pool: + take = rng.randint(1, min(4, len(pool))) + members, pool = pool[:take], pool[take:] + leaves = [{"id": m, "icon": rng.choice(_ICONS), "label": m.upper()} + for m in members] + if take > 1 and rng.random() < 0.6: + children.append({ + "id": f"g{len(children)}", + "groupType": rng.choice(_GROUP_TYPES), + "label": f"Group {len(children)}", + "direction": rng.choice(["horizontal", "vertical"]), + "children": leaves, + }) + else: + children.extend(leaves) + rng.shuffle(children) + + connections = [] + n_conns = rng.randint(1, min(8, n_leaves * 2)) + for _ in range(n_conns): + a, b = rng.sample(leaf_ids, 2) + conn = {"from": a, "to": b} + if rng.random() < 0.2: + conn["label"] = "conn" + if rng.random() < 0.15: + conn["fan"] = "merge" + connections.append(conn) + + return { + "direction": rng.choice(["horizontal", "vertical"]), + "children": children, + "connections": connections, + } + + +def _cases(): + for seed in range(_N_CASES): + yield seed, _random_tree(random.Random(seed)) + + +# The pipeline is the expensive part (~0.1-0.5s per tree), and several tests +# assert different invariants over the same layouts — build each case once. +_LAYOUT_CACHE: dict = {} +_RENDER_CACHE: dict = {} + + +def _built(seed, tree): + if seed not in _LAYOUT_CACHE: + _LAYOUT_CACHE[seed] = build_layout(tree, 0, 0, 1720, 800) + return _LAYOUT_CACHE[seed] + + +def _rendered(seed, tree): + if seed not in _RENDER_CACHE: + _RENDER_CACHE[seed] = render_architecture( + tree, x=0, y=0, width=1720, height=800) + return _RENDER_CACHE[seed] + + +# --------------------------------------------------------------------------- +# Pipeline invariants +# --------------------------------------------------------------------------- + +def test_render_never_crashes_and_shape_holds(): + for seed, tree in _cases(): + result = _rendered(seed, tree) + assert result["elements"], f"seed={seed}: no elements" + bbox = result["bbox"] + assert bbox["width"] > 0 and bbox["height"] > 0, f"seed={seed}" + m = result["metrics"] + for key in ("crossings", "pierces", "group_pierces", "overflow"): + assert m[key] >= 0, f"seed={seed}: negative {key}" + + +def test_all_leaves_are_placed(): + for seed, tree in _cases(): + nodes, groups, edges, rb, _h, _v = _built(seed, tree) + leaf_ids = {c["id"] for c in tree["children"] if "icon" in c} + for g in tree["children"]: + if "children" in g: + leaf_ids |= {c["id"] for c in g["children"]} + # Nodes inside a group are collected under a dot-qualified id + # ("g0.n7"), so match on the unqualified tail. + placed_tails = {nid.rsplit(".", 1)[-1] for nid in nodes} + assert leaf_ids <= placed_tails, ( + f"seed={seed}: missing {leaf_ids - placed_tails}") + # Every placed node has finite, positive dimensions. + for nid, n in nodes.items(): + assert n["width"] > 0 and n["height"] > 0, f"seed={seed}: {nid}" + + +def test_every_connection_gets_an_edge(): + for seed, tree in _cases(): + _nodes, _groups, edges, _rb, _h, _v = _built(seed, tree) + assert len(edges) == len(tree["connections"]), f"seed={seed}" + for e in edges: + pts = e.get("points", []) + assert len(pts) >= 2, f"seed={seed}: degenerate edge {e}" + + +def test_measure_matches_render_metrics(): + """measure() and render_architecture() must agree — they share the + pipeline, so a disagreement means the two entry points drifted (or the + pipeline is non-deterministic). Runs both entry points independently, + so keep the seed count small.""" + for seed, tree in list(_cases())[:5]: + rendered = render_architecture(tree, x=0, y=0, width=1720, height=800) + measured = measure(tree) + for key in ("crossings", "pierces", "group_pierces"): + assert rendered["metrics"][key] == measured[key], ( + f"seed={seed}: {key} render={rendered['metrics'][key]} " + f"measure={measured[key]}") + + +def test_score_is_monotone_in_each_defect(): + base = measure(_random_tree(random.Random(0))) + for key in ("crossings", "pierces", "group_pierces", "backwards"): + worse = dict(base, **{key: base[key] + 1}) + assert score(worse) > score(base), f"score not monotone in {key}" + + +# --------------------------------------------------------------------------- +# Geometric predicate properties +# --------------------------------------------------------------------------- + +def _random_segment(rng): + p1 = (rng.randint(0, 500), rng.randint(0, 500)) + p2 = (rng.randint(0, 500), rng.randint(0, 500)) + return p1, p2 + + +def test_segments_cross_is_symmetric(): + rng = random.Random(42) + for _ in range(300): + a1, a2 = _random_segment(rng) + b1, b2 = _random_segment(rng) + assert _segments_cross(a1, a2, b1, b2) == _segments_cross(b1, b2, a1, a2) + # Endpoint order within a segment must not matter either. + assert _segments_cross(a1, a2, b1, b2) == _segments_cross(a2, a1, b1, b2) + + +def test_segment_never_crosses_itself(): + rng = random.Random(43) + for _ in range(100): + a1, a2 = _random_segment(rng) + assert not _segments_cross(a1, a2, a1, a2) + + +def test_count_all_crossings_is_order_invariant(): + rng = random.Random(44) + for seed, tree in list(_cases())[:10]: + _n, _g, edges, _rb, _h, _v = _built(seed, tree) + if len(edges) < 2: + continue + baseline = _count_all_crossings(edges) + shuffled = list(edges) + rng.shuffle(shuffled) + assert _count_all_crossings(shuffled) == baseline, f"seed={seed}"