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 +108 -0
- archgraph/__main__.py +5 -0
- archgraph/cli.py +110 -0
- archgraph/geometry.py +256 -0
- archgraph/model.py +394 -0
- archgraph/parser.py +1508 -0
- archgraph/placement/__init__.py +0 -0
- archgraph/placement/congestion.py +108 -0
- archgraph/placement/detail.py +456 -0
- archgraph/placement/engine.py +133 -0
- archgraph/placement/floorplan.py +876 -0
- archgraph/placement/graphalgo.py +345 -0
- archgraph/placement/hierarchy.py +1086 -0
- archgraph/placement/layered.py +411 -0
- archgraph/placement/ports.py +354 -0
- archgraph/placement/sizing.py +234 -0
- archgraph/placement/vpsc.py +496 -0
- archgraph/quality.py +144 -0
- archgraph/render/__init__.py +0 -0
- archgraph/render/html.py +154 -0
- archgraph/render/jsonout.py +43 -0
- archgraph/render/png.py +35 -0
- archgraph/render/shapes.py +240 -0
- archgraph/render/svg.py +1077 -0
- archgraph/routing/__init__.py +162 -0
- archgraph/routing/grid.py +267 -0
- archgraph/routing/labels.py +141 -0
- archgraph/routing/nudge.py +291 -0
- archgraph/routing/router.py +1112 -0
- archgraph/styles.py +405 -0
- archgraph/textmetrics.py +92 -0
- archgraph_layout-1.0.0.dist-info/METADATA +245 -0
- archgraph_layout-1.0.0.dist-info/RECORD +37 -0
- archgraph_layout-1.0.0.dist-info/WHEEL +5 -0
- archgraph_layout-1.0.0.dist-info/entry_points.txt +2 -0
- archgraph_layout-1.0.0.dist-info/licenses/LICENSE +21 -0
- archgraph_layout-1.0.0.dist-info/top_level.txt +1 -0
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
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)
|