212 lines
9.1 KiB
Python
212 lines
9.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Assign edge connection points (ports) on an existing .drawio file.
|
|
|
|
draw.io's floating connections attach every edge of a node to the middle of
|
|
whichever side faces the other endpoint. When a node has several connections
|
|
leaving the same side they all land on the same point, so the lines stack and
|
|
overlap — the usual complaint on swimlane / cross-functional flowcharts, where
|
|
handoff edges between lanes share long orthogonal corridors.
|
|
|
|
This pass pins ``exitX/exitY`` and ``entryX/entryY`` instead:
|
|
|
|
1. Resolve every vertex to absolute coordinates (through swimlane/container
|
|
parents, via ``validate.abs_rect``).
|
|
2. For each edge end, pick the side of the node that faces the other endpoint
|
|
(whichever of dx/dy dominates, measured centre-to-centre).
|
|
3. Group the ends by (node, side) and sort each group along the side by the far
|
|
endpoint's position across that axis. Sorting by the far endpoint is what
|
|
removes crossings: two edges leaving the same side keep their relative order
|
|
instead of swapping over each other.
|
|
4. Spread the group evenly over the side — k ends get slots 1/(k+1) .. k/(k+1).
|
|
|
|
Only sides with 2+ ends are touched. Edges that already pin a port are left
|
|
alone, so hand-tuned geometry survives a re-run. Idempotent: running it twice
|
|
produces the same file.
|
|
|
|
This is a *port* assignment, not a router — it fixes lines stacking at the
|
|
shape boundary, not an edge crossing an unrelated shape in the middle of its
|
|
run. For that, add waypoints (see references/xml-authoring.md).
|
|
|
|
Usage:
|
|
python3 edgeports.py diagram.drawio # in place
|
|
python3 edgeports.py diagram.drawio -o routed.drawio
|
|
python3 edgeports.py diagram.drawio --dry-run # report only
|
|
"""
|
|
|
|
import argparse
|
|
import importlib.util
|
|
import os
|
|
import sys
|
|
import xml.etree.ElementTree as ET
|
|
|
|
_spec = importlib.util.spec_from_file_location(
|
|
"validate", os.path.join(os.path.dirname(os.path.abspath(__file__)), "validate.py"))
|
|
validate = importlib.util.module_from_spec(_spec)
|
|
_spec.loader.exec_module(validate)
|
|
|
|
# Port coordinates per side. Each entry is (fixed_axis_value, varies_along_x).
|
|
# 'varies_along_x' says which coordinate the evenly-spaced slot fills in.
|
|
SIDES = {
|
|
"N": (0.0, True), # top edge: y=0, x varies
|
|
"S": (1.0, True), # bottom edge: y=1, x varies
|
|
"W": (0.0, False), # left edge: x=0, y varies
|
|
"E": (1.0, False), # right edge: x=1, y varies
|
|
}
|
|
|
|
|
|
def centre(r):
|
|
x, y, w, h = r
|
|
return (x + w / 2.0, y + h / 2.0)
|
|
|
|
|
|
def side_facing(src_rect, dst_rect):
|
|
"""Which side of src faces dst: whichever of dx/dy dominates."""
|
|
sx, sy = centre(src_rect)
|
|
dx_, dy_ = centre(dst_rect)
|
|
dx, dy = dx_ - sx, dy_ - sy
|
|
if abs(dx) >= abs(dy):
|
|
return "E" if dx >= 0 else "W"
|
|
return "S" if dy >= 0 else "N"
|
|
|
|
|
|
def has_port(style, end):
|
|
"""True if the edge already pins this end's port (hand-tuned — leave it)."""
|
|
prefix = "exit" if end == "source" else "entry"
|
|
return (validate.style_num(style, prefix + "X") is not None
|
|
and validate.style_num(style, prefix + "Y") is not None)
|
|
|
|
|
|
def set_style(style, end, px, py):
|
|
"""Return style with this end's port keys set, other keys order-preserved."""
|
|
prefix = "exit" if end == "source" else "entry"
|
|
drop = {prefix + "X", prefix + "Y", prefix + "Dx", prefix + "Dy"}
|
|
parts = [p for p in (style or "").split(";")
|
|
if p and p.split("=", 1)[0] not in drop]
|
|
# Dx/Dy are perpendicular offsets in px; reset them so a re-run is stable.
|
|
parts += [f"{prefix}X={px:g}", f"{prefix}Y={py:g}",
|
|
f"{prefix}Dx=0", f"{prefix}Dy=0"]
|
|
return ";".join(parts) + ";"
|
|
|
|
|
|
def assign(cells, by_id):
|
|
"""Compute {(edge_elem, end): (px, py)} for every end worth pinning."""
|
|
rects = {}
|
|
for c in cells:
|
|
if c.get("vertex") == "1" and not validate.is_edge_label(c):
|
|
r = validate.abs_rect(c, by_id)
|
|
if r and not any(v != v for v in r): # NaN width/height guard
|
|
rects[c.get("id")] = r
|
|
|
|
# Collect ends: one entry per (edge, end) whose node and peer are known.
|
|
groups = {}
|
|
for e in cells:
|
|
if e.get("edge") != "1":
|
|
continue
|
|
style = e.get("style") or ""
|
|
src, dst = e.get("source"), e.get("target")
|
|
if src not in rects or dst not in rects:
|
|
continue # dangling — validate.py's job
|
|
for end, me, peer in (("source", src, dst), ("target", dst, src)):
|
|
if has_port(style, end):
|
|
continue
|
|
side = side_facing(rects[me], rects[peer])
|
|
groups.setdefault((me, side), []).append((e, end, rects[peer]))
|
|
|
|
ports = {}
|
|
for (node_id, side), ends in groups.items():
|
|
if len(ends) < 2:
|
|
continue # single edge: centre is fine
|
|
fixed, along_x = SIDES[side]
|
|
# Sort by the far endpoint's position across the axis we spread along.
|
|
# Tie-break on the other axis, then edge id, so the order is total and
|
|
# the output is deterministic.
|
|
ends.sort(key=lambda t: (centre(t[2])[0] if along_x else centre(t[2])[1],
|
|
centre(t[2])[1] if along_x else centre(t[2])[0],
|
|
t[0].get("id") or ""))
|
|
for i, (edge, end, _) in enumerate(ends):
|
|
slot = (i + 1) / float(len(ends) + 1)
|
|
ports[(edge, end)] = (slot, fixed) if along_x else (fixed, slot)
|
|
return ports
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("file", help="input .drawio")
|
|
ap.add_argument("-o", "--output", help="output path (default: edit in place)")
|
|
ap.add_argument("--dry-run", action="store_true",
|
|
help="report what would change, write nothing")
|
|
args = ap.parse_args()
|
|
|
|
try:
|
|
tree = ET.parse(args.file)
|
|
except ET.ParseError as exc:
|
|
sys.exit(f"error: {args.file} is not parseable XML ({exc}). "
|
|
"Compressed .drawio files must be saved uncompressed first.")
|
|
|
|
total = 0
|
|
for model in tree.getroot().iter("mxGraphModel"):
|
|
cells = list(model.iter("mxCell"))
|
|
by_id = {c.get("id"): c for c in cells if c.get("id")}
|
|
ports = assign(cells, by_id)
|
|
for (edge, end), (px, py) in ports.items():
|
|
edge.set("style", set_style(edge.get("style") or "", end, px, py))
|
|
total += len(ports)
|
|
|
|
if args.dry_run:
|
|
print(f"{total} edge end(s) would be pinned in {args.file}")
|
|
return
|
|
out = args.output or args.file
|
|
tree.write(out, encoding="utf-8", xml_declaration=False)
|
|
print(f"{total} edge end(s) pinned -> {out}")
|
|
|
|
|
|
def demo():
|
|
"""Self-check: three edges leaving one node's east side get distinct,
|
|
non-crossing ports, and a re-run is a no-op."""
|
|
xml = """<mxfile><diagram><mxGraphModel><root>
|
|
<mxCell id="0"/><mxCell id="1" parent="0"/>
|
|
<mxCell id="lane" vertex="1" parent="1">
|
|
<mxGeometry x="100" y="0" width="400" height="400" as="geometry"/></mxCell>
|
|
<mxCell id="hub" vertex="1" parent="lane">
|
|
<mxGeometry x="0" y="150" width="80" height="40" as="geometry"/></mxCell>
|
|
<mxCell id="a" vertex="1" parent="1">
|
|
<mxGeometry x="600" y="300" width="80" height="40" as="geometry"/></mxCell>
|
|
<mxCell id="b" vertex="1" parent="1">
|
|
<mxGeometry x="600" y="100" width="80" height="40" as="geometry"/></mxCell>
|
|
<mxCell id="c" vertex="1" parent="1">
|
|
<mxGeometry x="600" y="200" width="80" height="40" as="geometry"/></mxCell>
|
|
<mxCell id="e1" edge="1" parent="1" source="hub" target="a" style="rounded=1;"/>
|
|
<mxCell id="e2" edge="1" parent="1" source="hub" target="b" style="rounded=1;"/>
|
|
<mxCell id="e3" edge="1" parent="1" source="hub" target="c" style="rounded=1;"/>
|
|
<mxCell id="e4" edge="1" parent="1" source="hub" target="a"
|
|
style="rounded=1;exitX=1;exitY=0.9;"/>
|
|
</root></mxGraphModel></diagram></mxfile>"""
|
|
root = ET.fromstring(xml)
|
|
cells = list(root.iter("mxCell"))
|
|
by_id = {c.get("id"): c for c in cells if c.get("id")}
|
|
|
|
# hub sits inside 'lane' (x=100), so its absolute x is 100, not 0. Without
|
|
# parent resolution every target would look like it was to the west.
|
|
assert validate.abs_rect(by_id["hub"], by_id)[0] == 100.0
|
|
|
|
ports = assign(cells, by_id)
|
|
exits = {e.get("id"): p for (e, end), p in ports.items() if end == "source"}
|
|
assert set(exits) == {"e1", "e2", "e3"}, exits # e4 pre-pinned, untouched
|
|
assert all(x == 1.0 for x, _ in exits.values()) # all leave the east side
|
|
ys = [exits[i][1] for i in ("e2", "e3", "e1")] # targets ordered top->bottom
|
|
assert ys == sorted(ys), ys # ports follow => no crossing
|
|
assert len(set(ys)) == 3, ys # and no two stack
|
|
|
|
for (edge, end), (px, py) in ports.items():
|
|
edge.set("style", set_style(edge.get("style"), end, px, py))
|
|
assert not assign(cells, by_id), "second run must be a no-op"
|
|
print("ok")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if "--demo" in sys.argv:
|
|
demo()
|
|
else:
|
|
main()
|