archgraph-layout 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
archgraph/__init__.py ADDED
@@ -0,0 +1,108 @@
1
+ """archgraph — hierarchical architecture diagrams with floorplanning layout and orthogonal routing.
2
+
3
+ Typical use::
4
+
5
+ import archgraph
6
+ g = archgraph.build("system.xml") # parse + layout + route
7
+ open("system.svg", "w").write(archgraph.to_svg(g))
8
+
9
+ or from the command line::
10
+
11
+ archgraph render system.xml -o system.svg
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import time
17
+ from typing import Optional
18
+
19
+ from .model import ArchGraphError, Graph
20
+ from .parser import parse_file, parse_string
21
+ # import subpackages eagerly so the public functions below are not shadowed by them
22
+ from .placement import engine as _engine # noqa: E402
23
+ from . import routing as _routing # noqa: E402
24
+
25
+ __version__ = "1.0.0"
26
+ __all__ = ["load", "loads", "layout", "route", "build", "to_svg", "to_html", "to_json", "to_png", "save",
27
+ "ArchGraphError", "Graph", "__version__"]
28
+
29
+
30
+ def load(path: str) -> Graph:
31
+ """Parse an XML file into a :class:`Graph` (no layout yet)."""
32
+ return parse_file(path)
33
+
34
+
35
+ def loads(text: str, base_dir: Optional[str] = None) -> Graph:
36
+ """Parse XML text into a :class:`Graph`."""
37
+ return parse_string(text, base_dir=base_dir)
38
+
39
+
40
+ def layout(g: Graph) -> Graph:
41
+ """Place every block and subsystem (and assign ports)."""
42
+ _engine.layout(g)
43
+ return g
44
+
45
+
46
+ def route(g: Graph) -> Graph:
47
+ """Route all connections and buses, nudge them apart and place labels."""
48
+ _routing.route(g)
49
+ from .geometry import Rect
50
+ rects = [e.rect for e in g.root.iter_descendants()]
51
+ g.bbox = Rect.bounding(rects)
52
+ return g
53
+
54
+
55
+ def build(source: str, **overrides) -> Graph:
56
+ """Parse (file path or XML text), apply setting overrides, lay out and route."""
57
+ t0 = time.time()
58
+ if os.path.exists(source) or source.lower().endswith(".xml"):
59
+ g = parse_file(source)
60
+ else:
61
+ g = parse_string(source)
62
+ from .parser import apply_setting
63
+ errs = []
64
+ for k, v in overrides.items():
65
+ if v is not None:
66
+ apply_setting(g.settings, k, str(v), errs, "override")
67
+ if errs:
68
+ raise ArchGraphError(errs)
69
+ layout(g)
70
+ route(g)
71
+ if g.settings.routing == "orthogonal" and g.settings.num_spacing_passes() > 0:
72
+ _engine.expand_congested(g, route, g.settings.num_spacing_passes())
73
+ g.layout_stats["total_time"] = time.time() - t0
74
+ return g
75
+
76
+
77
+ def to_svg(g: Graph) -> str:
78
+ from .render.svg import render_svg
79
+ return render_svg(g)
80
+
81
+
82
+ def to_html(g: Graph) -> str:
83
+ from .render.html import render_html
84
+ return render_html(g, to_svg(g))
85
+
86
+
87
+ def to_json(g: Graph) -> str:
88
+ from .render.jsonout import render_json
89
+ return render_json(g)
90
+
91
+
92
+ def to_png(g: Graph, path: str, scale: float = 2.0) -> None:
93
+ from .render.png import render_png
94
+ render_png(to_svg(g), path, scale)
95
+
96
+
97
+ def save(g: Graph, path: str, fmt: Optional[str] = None, scale: float = 2.0) -> str:
98
+ """Write the diagram; the format follows the extension unless ``fmt`` is given."""
99
+ fmt = (fmt or os.path.splitext(path)[1].lstrip(".") or "svg").lower()
100
+ if fmt == "png":
101
+ to_png(g, path, scale)
102
+ return path
103
+ text = {"svg": to_svg, "html": to_html, "htm": to_html, "json": to_json}.get(fmt)
104
+ if text is None:
105
+ raise ArchGraphError(f"unsupported output format '{fmt}' (svg, html, json, png)")
106
+ with open(path, "w", encoding="utf-8") as fh:
107
+ fh.write(text(g))
108
+ return path
archgraph/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
archgraph/cli.py ADDED
@@ -0,0 +1,110 @@
1
+ """Command line interface: ``archgraph render|validate|stats|expand``."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ import sys
8
+ import time
9
+
10
+ from . import __version__
11
+ from .model import ArchGraphError
12
+
13
+
14
+ def _common(p):
15
+ p.add_argument("input", help="XML diagram description")
16
+ p.add_argument("--set", action="append", default=[], metavar="KEY=VALUE",
17
+ help="override a setting, e.g. --set aspect=4:3 --set theme=dark (repeatable)")
18
+ p.add_argument("--quality", choices=["draft", "normal", "high", "max"], help="layout effort")
19
+ p.add_argument("--theme", help="light | dark | blueprint | mono | pastel")
20
+ p.add_argument("--aspect", help="target aspect ratio, e.g. 16:9, 1.5, square")
21
+ p.add_argument("--direction", help="default flow LR | RL | TB | BT")
22
+ p.add_argument("--seed", type=int, help="random seed (layouts are deterministic per seed)")
23
+
24
+
25
+ def _overrides(args):
26
+ ov = {}
27
+ for item in args.set:
28
+ if "=" not in item:
29
+ raise ArchGraphError(f"--set expects KEY=VALUE, got '{item}'")
30
+ k, v = item.split("=", 1)
31
+ ov[k.strip()] = v.strip()
32
+ for k in ("quality", "theme", "aspect", "direction", "seed"):
33
+ v = getattr(args, k, None)
34
+ if v is not None:
35
+ ov[k] = v
36
+ return ov
37
+
38
+
39
+ def main(argv=None) -> int:
40
+ ap = argparse.ArgumentParser(prog="archgraph", description="Hierarchical architecture diagrams from XML.")
41
+ ap.add_argument("--version", action="version", version=f"archgraph {__version__}")
42
+ sub = ap.add_subparsers(dest="cmd")
43
+ r = sub.add_parser("render", help="lay out, route and render a diagram")
44
+ _common(r)
45
+ r.add_argument("-o", "--output", action="append", default=[],
46
+ help="output file(s); format from extension: .svg .html .json .png (repeatable)")
47
+ r.add_argument("--scale", type=float, default=2.0, help="PNG pixel scale")
48
+ r.add_argument("--stats", action="store_true", help="print layout quality metrics")
49
+ v = sub.add_parser("validate", help="check the XML and report errors/warnings")
50
+ v.add_argument("input")
51
+ s = sub.add_parser("stats", help="lay out and print quality metrics as JSON")
52
+ _common(s)
53
+ e = sub.add_parser("expand", help="print the XML after templates/repeats/includes are expanded")
54
+ e.add_argument("input")
55
+ args = ap.parse_args(argv)
56
+ if not args.cmd:
57
+ # shorthand: archgraph file.xml [-o out]
58
+ if argv is None:
59
+ argv = sys.argv[1:]
60
+ if argv and not argv[0].startswith("-"):
61
+ return main(["render"] + list(argv))
62
+ ap.print_help()
63
+ return 1
64
+ try:
65
+ if args.cmd == "validate":
66
+ from .parser import parse_file
67
+ g = parse_file(args.input)
68
+ n = len(g.nodes())
69
+ print(f"OK: {n} blocks, {len(g.subsystems())} subsystems, {len(g.edges)} connections, "
70
+ f"{len(g.buses)} buses, {len(g.hints)} layout hints")
71
+ for w in g.warnings:
72
+ print("warning:", w)
73
+ return 0
74
+ if args.cmd == "expand":
75
+ import xml.etree.ElementTree as ET
76
+ from .parser import parse_file
77
+ g = parse_file(args.input)
78
+ root = g.extras["expanded_xml"]
79
+ for el in root.iter():
80
+ for k in [k for k in el.attrib if k.startswith("__")]:
81
+ del el.attrib[k]
82
+ ET.indent(root)
83
+ print(ET.tostring(root, encoding="unicode"))
84
+ return 0
85
+ from . import build, save
86
+ from .quality import metrics
87
+ t0 = time.time()
88
+ g = build(args.input, **_overrides(args))
89
+ for w in g.warnings:
90
+ print("warning:", w, file=sys.stderr)
91
+ if args.cmd == "stats":
92
+ print(json.dumps(metrics(g), indent=1))
93
+ return 0
94
+ outs = args.output or [os.path.splitext(args.input)[0] + ".svg"]
95
+ for o in outs:
96
+ save(g, o, scale=args.scale)
97
+ print(f"wrote {o}")
98
+ if args.stats:
99
+ print(json.dumps(metrics(g), indent=1))
100
+ print(f"done in {time.time() - t0:.1f}s", file=sys.stderr)
101
+ return 0
102
+ except ArchGraphError as ex:
103
+ print("error:", file=sys.stderr)
104
+ for m in ex.messages:
105
+ print(" " + m, file=sys.stderr)
106
+ return 2
107
+
108
+
109
+ if __name__ == "__main__":
110
+ sys.exit(main())
archgraph/geometry.py ADDED
@@ -0,0 +1,256 @@
1
+ """Small geometry toolkit used throughout archgraph.
2
+
3
+ Coordinates follow SVG conventions: x grows to the right, y grows downward.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import math
8
+ from dataclasses import dataclass
9
+ from typing import Iterable, List, Optional, Sequence, Tuple
10
+
11
+ Point = Tuple[float, float]
12
+
13
+ EPS = 1e-6
14
+
15
+ # Side constants (compass notation). N is "up" (smaller y).
16
+ N, E, S, W = "N", "E", "S", "W"
17
+ SIDES = (N, E, S, W)
18
+ SIDE_NORMALS = {N: (0.0, -1.0), E: (1.0, 0.0), S: (0.0, 1.0), W: (-1.0, 0.0)}
19
+ OPPOSITE = {N: S, S: N, E: W, W: E}
20
+
21
+ SIDE_ALIASES = {
22
+ "n": N, "north": N, "top": N, "up": N, "t": N,
23
+ "e": E, "east": E, "right": E, "r": E,
24
+ "s": S, "south": S, "bottom": S, "down": S, "b": S,
25
+ "w": W, "west": W, "left": W, "l": W,
26
+ }
27
+
28
+
29
+ def parse_side(value: Optional[str]) -> Optional[str]:
30
+ if value is None:
31
+ return None
32
+ v = str(value).strip().lower()
33
+ if v in ("", "auto", "any", "none"):
34
+ return None
35
+ if v not in SIDE_ALIASES:
36
+ raise ValueError(f"unknown side '{value}' (use north/east/south/west)")
37
+ return SIDE_ALIASES[v]
38
+
39
+
40
+ @dataclass
41
+ class Rect:
42
+ x0: float
43
+ y0: float
44
+ x1: float
45
+ y1: float
46
+
47
+ @staticmethod
48
+ def from_center(cx: float, cy: float, w: float, h: float) -> "Rect":
49
+ return Rect(cx - w / 2.0, cy - h / 2.0, cx + w / 2.0, cy + h / 2.0)
50
+
51
+ @staticmethod
52
+ def bounding(rects: Iterable["Rect"]) -> Optional["Rect"]:
53
+ rs = list(rects)
54
+ if not rs:
55
+ return None
56
+ return Rect(min(r.x0 for r in rs), min(r.y0 for r in rs),
57
+ max(r.x1 for r in rs), max(r.y1 for r in rs))
58
+
59
+ @staticmethod
60
+ def of_points(pts: Iterable[Point]) -> Optional["Rect"]:
61
+ pts = list(pts)
62
+ if not pts:
63
+ return None
64
+ xs = [p[0] for p in pts]
65
+ ys = [p[1] for p in pts]
66
+ return Rect(min(xs), min(ys), max(xs), max(ys))
67
+
68
+ @property
69
+ def w(self) -> float:
70
+ return self.x1 - self.x0
71
+
72
+ @property
73
+ def h(self) -> float:
74
+ return self.y1 - self.y0
75
+
76
+ @property
77
+ def cx(self) -> float:
78
+ return (self.x0 + self.x1) / 2.0
79
+
80
+ @property
81
+ def cy(self) -> float:
82
+ return (self.y0 + self.y1) / 2.0
83
+
84
+ @property
85
+ def area(self) -> float:
86
+ return max(0.0, self.w) * max(0.0, self.h)
87
+
88
+ def inflate(self, dx: float, dy: Optional[float] = None) -> "Rect":
89
+ if dy is None:
90
+ dy = dx
91
+ return Rect(self.x0 - dx, self.y0 - dy, self.x1 + dx, self.y1 + dy)
92
+
93
+ def translate(self, dx: float, dy: float) -> "Rect":
94
+ return Rect(self.x0 + dx, self.y0 + dy, self.x1 + dx, self.y1 + dy)
95
+
96
+ def contains_point(self, x: float, y: float, strict: bool = False) -> bool:
97
+ if strict:
98
+ return self.x0 + EPS < x < self.x1 - EPS and self.y0 + EPS < y < self.y1 - EPS
99
+ return self.x0 - EPS <= x <= self.x1 + EPS and self.y0 - EPS <= y <= self.y1 + EPS
100
+
101
+ def contains_rect(self, o: "Rect", tol: float = EPS) -> bool:
102
+ return (o.x0 >= self.x0 - tol and o.y0 >= self.y0 - tol and
103
+ o.x1 <= self.x1 + tol and o.y1 <= self.y1 + tol)
104
+
105
+ def overlaps(self, o: "Rect", tol: float = EPS) -> bool:
106
+ return (self.x0 < o.x1 - tol and o.x0 < self.x1 - tol and
107
+ self.y0 < o.y1 - tol and o.y0 < self.y1 - tol)
108
+
109
+ def overlap_area(self, o: "Rect") -> float:
110
+ dx = min(self.x1, o.x1) - max(self.x0, o.x0)
111
+ dy = min(self.y1, o.y1) - max(self.y0, o.y0)
112
+ if dx <= 0 or dy <= 0:
113
+ return 0.0
114
+ return dx * dy
115
+
116
+ def union(self, o: "Rect") -> "Rect":
117
+ return Rect(min(self.x0, o.x0), min(self.y0, o.y0), max(self.x1, o.x1), max(self.y1, o.y1))
118
+
119
+ def side_point(self, side: str, t: float = 0.5) -> Point:
120
+ """Point on a side; t in [0,1] runs left->right (N/S) or top->bottom (E/W)."""
121
+ if side == N:
122
+ return (self.x0 + t * self.w, self.y0)
123
+ if side == S:
124
+ return (self.x0 + t * self.w, self.y1)
125
+ if side == E:
126
+ return (self.x1, self.y0 + t * self.h)
127
+ return (self.x0, self.y0 + t * self.h)
128
+
129
+ def as_tuple(self) -> Tuple[float, float, float, float]:
130
+ return (self.x0, self.y0, self.x1, self.y1)
131
+
132
+
133
+ def manhattan(a: Point, b: Point) -> float:
134
+ return abs(a[0] - b[0]) + abs(a[1] - b[1])
135
+
136
+
137
+ def dist(a: Point, b: Point) -> float:
138
+ return math.hypot(a[0] - b[0], a[1] - b[1])
139
+
140
+
141
+ def segment_intersects_rect(p: Point, q: Point, r: Rect, strict: bool = True) -> bool:
142
+ """Liang-Barsky clip test. ``strict`` ignores touching the boundary."""
143
+ x0, y0 = p
144
+ dx, dy = q[0] - p[0], q[1] - p[1]
145
+ shrink = EPS * 10 if strict else -EPS
146
+ rx0, ry0, rx1, ry1 = r.x0 + shrink, r.y0 + shrink, r.x1 - shrink, r.y1 - shrink
147
+ if rx0 >= rx1 or ry0 >= ry1:
148
+ return False
149
+ t0, t1 = 0.0, 1.0
150
+ for pp, qq in ((-dx, x0 - rx0), (dx, rx1 - x0), (-dy, y0 - ry0), (dy, ry1 - y0)):
151
+ if abs(pp) < 1e-12:
152
+ if qq < 0:
153
+ return False
154
+ else:
155
+ t = qq / pp
156
+ if pp < 0:
157
+ if t > t1:
158
+ return False
159
+ t0 = max(t0, t)
160
+ else:
161
+ if t < t0:
162
+ return False
163
+ t1 = min(t1, t)
164
+ return t0 <= t1
165
+
166
+
167
+ def _orient(a: Point, b: Point, c: Point) -> float:
168
+ return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
169
+
170
+
171
+ def segments_cross(a: Point, b: Point, c: Point, d: Point) -> bool:
172
+ """True if open segments ab and cd properly cross (shared endpoints do not count)."""
173
+ o1 = _orient(a, b, c)
174
+ o2 = _orient(a, b, d)
175
+ o3 = _orient(c, d, a)
176
+ o4 = _orient(c, d, b)
177
+ return (o1 * o2 < -1e-9) and (o3 * o4 < -1e-9)
178
+
179
+
180
+ def simplify_polyline(pts: Sequence[Point], tol: float = 1e-6) -> List[Point]:
181
+ """Remove duplicate and collinear interior points of an axis-parallel polyline."""
182
+ out: List[Point] = []
183
+ for p in pts:
184
+ p = (float(p[0]), float(p[1]))
185
+ if out and abs(out[-1][0] - p[0]) < tol and abs(out[-1][1] - p[1]) < tol:
186
+ continue
187
+ out.append(p)
188
+ changed = True
189
+ while changed and len(out) >= 3:
190
+ changed = False
191
+ res = [out[0]]
192
+ for i in range(1, len(out) - 1):
193
+ a, b, c = res[-1], out[i], out[i + 1]
194
+ if abs(_orient(a, b, c)) < tol * max(1.0, dist(a, c)):
195
+ # b is collinear; drop it only if it lies between a and c
196
+ if (min(a[0], c[0]) - tol <= b[0] <= max(a[0], c[0]) + tol and
197
+ min(a[1], c[1]) - tol <= b[1] <= max(a[1], c[1]) + tol):
198
+ changed = True
199
+ continue
200
+ res.append(b)
201
+ res.append(out[-1])
202
+ out = res
203
+ return out
204
+
205
+
206
+ def polyline_length(pts: Sequence[Point]) -> float:
207
+ return sum(dist(pts[i], pts[i + 1]) for i in range(len(pts) - 1))
208
+
209
+
210
+ def polyline_point_at(pts: Sequence[Point], frac: float) -> Tuple[Point, int]:
211
+ """Point at fraction ``frac`` of the total length; also returns segment index."""
212
+ total = polyline_length(pts)
213
+ if total <= 0:
214
+ return pts[0], 0
215
+ target = frac * total
216
+ acc = 0.0
217
+ for i in range(len(pts) - 1):
218
+ seg = dist(pts[i], pts[i + 1])
219
+ if acc + seg >= target - 1e-9:
220
+ t = 0.0 if seg == 0 else (target - acc) / seg
221
+ p = (pts[i][0] + t * (pts[i + 1][0] - pts[i][0]), pts[i][1] + t * (pts[i + 1][1] - pts[i][1]))
222
+ return p, i
223
+ acc += seg
224
+ return pts[-1], len(pts) - 2
225
+
226
+
227
+ def count_bends(pts: Sequence[Point]) -> int:
228
+ return max(0, len(simplify_polyline(pts)) - 2)
229
+
230
+
231
+ def side_of_point(rect: Rect, x: float, y: float) -> str:
232
+ """Side of ``rect`` closest in direction to the point, normalised by rect size."""
233
+ dx = (x - rect.cx) / max(rect.w / 2.0, 1.0)
234
+ dy = (y - rect.cy) / max(rect.h / 2.0, 1.0)
235
+ if abs(dx) >= abs(dy):
236
+ return E if dx >= 0 else W
237
+ return S if dy >= 0 else N
238
+
239
+
240
+ def parse_aspect(value) -> Optional[float]:
241
+ """Accepts '16:9', '1.6', 'square', 'wide', 'tall', 'auto'."""
242
+ if value is None:
243
+ return None
244
+ if isinstance(value, (int, float)):
245
+ return float(value)
246
+ v = str(value).strip().lower()
247
+ named = {"square": 1.0, "wide": 16 / 9, "widescreen": 16 / 9, "tall": 9 / 16, "portrait": 1 / math.sqrt(2),
248
+ "landscape": math.sqrt(2), "a4": math.sqrt(2), "letter": 11 / 8.5, "golden": 1.618, "auto": None,
249
+ "free": None, "none": None}
250
+ if v in named:
251
+ return named[v]
252
+ for sep in (":", "/", "x"):
253
+ if sep in v:
254
+ a, b = v.split(sep, 1)
255
+ return float(a) / float(b)
256
+ return float(v)