predictygraph 0.1.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.
- predictygraph/__init__.py +108 -0
- predictygraph/__main__.py +7 -0
- predictygraph/anim.py +91 -0
- predictygraph/cli.py +62 -0
- predictygraph/default.css +16 -0
- predictygraph/fonts/Carlito-Bold.ttf +0 -0
- predictygraph/fonts/Carlito-Regular.ttf +0 -0
- predictygraph/fonts/DejaVuSans-Bold.ttf +0 -0
- predictygraph/fonts/DejaVuSans.ttf +0 -0
- predictygraph/fonts/LiberationMono-Bold.ttf +0 -0
- predictygraph/fonts/LiberationMono-Regular.ttf +0 -0
- predictygraph/fonts/LiberationSans-Bold.ttf +0 -0
- predictygraph/fonts/LiberationSans-Regular.ttf +0 -0
- predictygraph/fonts/LiberationSerif-Bold.ttf +0 -0
- predictygraph/fonts/LiberationSerif-Regular.ttf +0 -0
- predictygraph/fonts.py +167 -0
- predictygraph/ir.py +62 -0
- predictygraph/layout.py +534 -0
- predictygraph/model.py +98 -0
- predictygraph/parser.py +310 -0
- predictygraph/render/__init__.py +17 -0
- predictygraph/render/apng.py +43 -0
- predictygraph/render/svg.py +121 -0
- predictygraph/route.py +453 -0
- predictygraph/safe.py +41 -0
- predictygraph/scene.py +349 -0
- predictygraph/style.py +204 -0
- predictygraph/text.py +80 -0
- predictygraph/theme.py +107 -0
- predictygraph/themes/catppuccin-frappe.toml +27 -0
- predictygraph/themes/catppuccin-latte.toml +27 -0
- predictygraph/themes/catppuccin-macchiato.toml +27 -0
- predictygraph/themes/catppuccin-mocha.toml +27 -0
- predictygraph/themes/default.toml +22 -0
- predictygraph/themes/dracula.toml +25 -0
- predictygraph/themes/github-light.toml +25 -0
- predictygraph/themes/gruvbox-dark.toml +24 -0
- predictygraph/themes/nord.toml +25 -0
- predictygraph/themes/solarized-dark.toml +15 -0
- predictygraph/themes/solarized-light.toml +24 -0
- predictygraph-0.1.0.dist-info/METADATA +60 -0
- predictygraph-0.1.0.dist-info/RECORD +44 -0
- predictygraph-0.1.0.dist-info/WHEEL +4 -0
- predictygraph-0.1.0.dist-info/entry_points.txt +6 -0
predictygraph/route.py
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 The predictygraph Authors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
"""Edge routing (format-independent): ports, lanes, bus levels and octilinear paths.
|
|
6
|
+
|
|
7
|
+
The router works on the laid-out AST and decides *where* every edge goes. It reports
|
|
8
|
+
`needs` back to the layout when a channel between two ranks is too narrow for the lanes
|
|
9
|
+
that have to pass through it; the pipeline then widens that gap and routes again.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import math
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
from .layout import direct_child
|
|
17
|
+
from .model import Document, Edge, Group
|
|
18
|
+
from .style import px
|
|
19
|
+
|
|
20
|
+
LANE = 9.0 # distance between parallel lanes
|
|
21
|
+
CLEAR = 12.0 # min distance between a horizontal run and the boxes above/below it
|
|
22
|
+
RECT_SHAPES = {"box", "square", "round", "pill", "cylinder", "note", "image", "none"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def lca(a, b, root):
|
|
26
|
+
aa = [a] + a.ancestors()
|
|
27
|
+
bb = set(id(x) for x in [b] + b.ancestors())
|
|
28
|
+
for x in aa:
|
|
29
|
+
if id(x) in bb and isinstance(x, Group):
|
|
30
|
+
return x
|
|
31
|
+
return root
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def flow_dir(g: Group) -> str:
|
|
35
|
+
if g.attrs.get("layout", "layered") in ("layered",):
|
|
36
|
+
return g.attrs.get("dir", "TB").upper()
|
|
37
|
+
if g.attrs.get("layout") == "row":
|
|
38
|
+
return "LR"
|
|
39
|
+
if g.attrs.get("layout") == "column":
|
|
40
|
+
return "TB"
|
|
41
|
+
return "auto"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def boundary_point(n, tx, ty, shape):
|
|
45
|
+
"""Intersection of ray centre->(tx,ty) with node outline."""
|
|
46
|
+
cx, cy = n.cx, n.cy
|
|
47
|
+
dx, dy = tx - cx, ty - cy
|
|
48
|
+
if dx == 0 and dy == 0:
|
|
49
|
+
return cx, cy
|
|
50
|
+
hw, hh = n.w / 2, n.h / 2
|
|
51
|
+
if shape in ("circle", "ellipse"):
|
|
52
|
+
t = 1 / math.sqrt((dx / hw) ** 2 + (dy / hh) ** 2)
|
|
53
|
+
elif shape == "diamond":
|
|
54
|
+
t = 1 / (abs(dx) / hw + abs(dy) / hh)
|
|
55
|
+
else:
|
|
56
|
+
t = min(hw / abs(dx) if dx else math.inf, hh / abs(dy) if dy else math.inf)
|
|
57
|
+
return cx + dx * t, cy + dy * t
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def port(n, side):
|
|
61
|
+
return {"top": (n.cx, n.ay), "bottom": (n.cx, n.ay + n.h),
|
|
62
|
+
"left": (n.ax, n.cy), "right": (n.ax + n.w, n.cy)}[side]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class Diagonal:
|
|
67
|
+
"""When may a bend/ortho jog be a slant instead of a right-angle step?"""
|
|
68
|
+
mode: str = "auto"
|
|
69
|
+
max: float = 40.0
|
|
70
|
+
angle: float = 45.0
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def from_style(cls, st: dict, route: str) -> "Diagonal":
|
|
74
|
+
dg = str(st.get("diagonal", "40" if route == "bend" else "0")).strip()
|
|
75
|
+
fixed = {"none": 0.0, "always": 1e9}
|
|
76
|
+
return cls("legacy" if dg == "legacy" else "auto",
|
|
77
|
+
fixed[dg] if dg in fixed else px(dg, 40.0),
|
|
78
|
+
min(80.0, max(10.0, px(st.get("diagonal-angle"), 45.0))))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def octilinear(p0, p1, axis, mid, stub, diag: Diagonal):
|
|
82
|
+
"""Route between two ports with horizontal/vertical runs and at most one diagonal.
|
|
83
|
+
|
|
84
|
+
main = distance along the flow axis, cross = sideways offset to make up.
|
|
85
|
+
* cross ~ 0 -> one straight line
|
|
86
|
+
* cross <= diag.max and the slant fits between the stubs -> stub, slant, stub
|
|
87
|
+
* otherwise -> stub, perpendicular run, stub (Z shape)
|
|
88
|
+
The jog is centred on `mid` (lane / channel level) if given.
|
|
89
|
+
"""
|
|
90
|
+
(x0, y0), (x1, y1) = p0, p1
|
|
91
|
+
v = axis == "v"
|
|
92
|
+
a0, a1 = (y0, y1) if v else (x0, x1) # main axis
|
|
93
|
+
c0, c1 = (x0, x1) if v else (y0, y1) # cross axis
|
|
94
|
+
main, cross = a1 - a0, c1 - c0
|
|
95
|
+
sgn = 1.0 if main >= 0 else -1.0
|
|
96
|
+
|
|
97
|
+
def P(a, c):
|
|
98
|
+
return (c, a) if v else (a, c)
|
|
99
|
+
|
|
100
|
+
if abs(cross) < 0.5:
|
|
101
|
+
return [p0, p1]
|
|
102
|
+
angle = diag.angle
|
|
103
|
+
stub = min(stub, abs(main) / 2)
|
|
104
|
+
if mid is not None: # a lane/channel level wins over the default stub length
|
|
105
|
+
stub = max(2.0, min(stub, abs(mid - a0), abs(a1 - mid)))
|
|
106
|
+
room = abs(main) - 2 * stub
|
|
107
|
+
if diag.max >= 1e9 and room > 1: # diagonal=always: steepen until the slant fits
|
|
108
|
+
angle = max(angle, math.degrees(math.atan2(abs(cross), room)))
|
|
109
|
+
dlen = abs(cross) / math.tan(math.radians(angle)) # main-axis length the slant uses
|
|
110
|
+
centre = mid if mid is not None else (a0 + a1) / 2
|
|
111
|
+
if abs(cross) <= diag.max and dlen <= room:
|
|
112
|
+
if sgn > 0:
|
|
113
|
+
s = max(a0 + stub, min(centre - dlen / 2, a1 - (stub + dlen)))
|
|
114
|
+
else:
|
|
115
|
+
s = min(a0 - stub, max(centre + dlen / 2, a1 + (stub + dlen)))
|
|
116
|
+
return [p0, P(s, c0), P(s + sgn * dlen, c1), p1]
|
|
117
|
+
if sgn > 0:
|
|
118
|
+
m = max(a0 + stub, min(centre, a1 - stub))
|
|
119
|
+
else:
|
|
120
|
+
m = min(a0 - stub, max(centre, a1 + stub))
|
|
121
|
+
return [p0, P(m, c0), P(m, c1), p1]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def rounded(pts, r):
|
|
125
|
+
"""Polyline -> path segments with rounded corners (quadratic)."""
|
|
126
|
+
if len(pts) < 3:
|
|
127
|
+
return [("M", *pts[0])] + [("L", *p) for p in pts[1:]]
|
|
128
|
+
segs = [("M", *pts[0])]
|
|
129
|
+
for p0, p1, p2 in zip(pts, pts[1:], pts[2:]):
|
|
130
|
+
v1 = (p1[0] - p0[0], p1[1] - p0[1])
|
|
131
|
+
v2 = (p2[0] - p1[0], p2[1] - p1[1])
|
|
132
|
+
l1, l2 = math.hypot(*v1), math.hypot(*v2)
|
|
133
|
+
if l1 == 0 or l2 == 0:
|
|
134
|
+
continue
|
|
135
|
+
rr = min(r, l1 / 2, l2 / 2)
|
|
136
|
+
a = (p1[0] - v1[0] / l1 * rr, p1[1] - v1[1] / l1 * rr)
|
|
137
|
+
b = (p1[0] + v2[0] / l2 * rr, p1[1] + v2[1] / l2 * rr)
|
|
138
|
+
segs += [("L", *a), ("Q", *p1, *b)]
|
|
139
|
+
segs.append(("L", *pts[-1]))
|
|
140
|
+
return segs
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class Router:
|
|
144
|
+
def __init__(self, doc: Document):
|
|
145
|
+
self.doc = doc
|
|
146
|
+
self.offset: dict[str, float] = {}
|
|
147
|
+
self.port_shift: dict[tuple[str, int], float] = {}
|
|
148
|
+
self.bend_level: dict[str, float] = {}
|
|
149
|
+
self.channel: dict[str, tuple[float, float]] = {}
|
|
150
|
+
self.gapkey: dict[str, tuple[str, int]] = {}
|
|
151
|
+
self.needs: dict[tuple[str, int], float] = {}
|
|
152
|
+
|
|
153
|
+
def route(self) -> "Router":
|
|
154
|
+
self._parallel_offsets()
|
|
155
|
+
self._spread_ports()
|
|
156
|
+
self._shared_bends()
|
|
157
|
+
self._separate_channels()
|
|
158
|
+
return self
|
|
159
|
+
|
|
160
|
+
def level(self, e: Edge) -> float | None:
|
|
161
|
+
"""Where the sideways jog of `e` sits on the flow axis."""
|
|
162
|
+
if e.id in self.bend_level:
|
|
163
|
+
return self.bend_level[e.id]
|
|
164
|
+
ch = self.channel.get(e.id)
|
|
165
|
+
return (ch[0] + ch[1]) / 2 if ch else None
|
|
166
|
+
|
|
167
|
+
# ------------------------------------------------------------ passes
|
|
168
|
+
def _parallel_offsets(self):
|
|
169
|
+
pairs: dict[tuple[str, str], list[Edge]] = {}
|
|
170
|
+
for e in self.doc.edges:
|
|
171
|
+
pairs.setdefault(tuple(sorted((e.src, e.dst))), []).append(e)
|
|
172
|
+
for es in pairs.values():
|
|
173
|
+
n = len(es)
|
|
174
|
+
for i, e in enumerate(es):
|
|
175
|
+
k = (i - (n - 1) / 2) * 12
|
|
176
|
+
# opposite-direction edges get mirrored sign so they stay apart
|
|
177
|
+
self.offset[e.id] = k if e.src <= e.dst else -k
|
|
178
|
+
|
|
179
|
+
def _spread_ports(self):
|
|
180
|
+
"""Several curved/bent edges on one side of a node: spread them symmetrically
|
|
181
|
+
around the side's middle (ordered by where they go)."""
|
|
182
|
+
sides: dict[tuple[str, str], list] = {}
|
|
183
|
+
for e in self.doc.edges:
|
|
184
|
+
if e.attrs.get("exit") or e.attrs.get("enter") or self.offset.get(e.id):
|
|
185
|
+
continue
|
|
186
|
+
pts, axis = self.geometry(e)
|
|
187
|
+
if axis not in ("v", "h"):
|
|
188
|
+
continue
|
|
189
|
+
a, b = self.doc.items[e.src], self.doc.items[e.dst]
|
|
190
|
+
if flow_dir(lca(a, b, self.doc.root)) == "auto" or e.style.get("route", "curve") in ("straight", "ortho"):
|
|
191
|
+
continue # ortho fan-in/out merges into a bus instead
|
|
192
|
+
i = 0 if axis == "v" else 1
|
|
193
|
+
for end, node, here, there in ((0, a, pts[0], pts[1] if len(pts) > 2 else pts[-1]),
|
|
194
|
+
(1, b, pts[-1], pts[-2] if len(pts) > 2 else pts[0])):
|
|
195
|
+
side = ("bottom" if there[1] > here[1] else "top") if axis == "v" else \
|
|
196
|
+
("right" if there[0] > here[0] else "left")
|
|
197
|
+
sides.setdefault((node.id, side), []).append((there[i], e.id, end))
|
|
198
|
+
for (nid, side), lst in sides.items():
|
|
199
|
+
if len(lst) < 2:
|
|
200
|
+
continue
|
|
201
|
+
n = self.doc.items[nid]
|
|
202
|
+
if n.attrs.get("shape", "box") in ("diamond", "circle", "ellipse") and not isinstance(n, Group):
|
|
203
|
+
continue
|
|
204
|
+
span = n.w if side in ("top", "bottom") else n.h
|
|
205
|
+
step = min(18.0, (span - 12) / (len(lst) - 1)) if span > 12 else 0.0
|
|
206
|
+
lst.sort()
|
|
207
|
+
for k, (_, eid, end) in enumerate(lst):
|
|
208
|
+
self.port_shift[(eid, end)] = (k - (len(lst) - 1) / 2) * step
|
|
209
|
+
|
|
210
|
+
def _shared_bends(self):
|
|
211
|
+
"""Ortho fan-in/fan-out: edges sharing a target (or source) turn at one common
|
|
212
|
+
level, so they merge into a single bus line (class-diagram style)."""
|
|
213
|
+
geo = {e.id: self.geometry(e) for e in self.doc.edges if e.style.get("route", "curve") == "ortho"}
|
|
214
|
+
for key in ("dst", "src"):
|
|
215
|
+
buckets: dict[str, list] = {}
|
|
216
|
+
for e in self.doc.edges:
|
|
217
|
+
if e.id in geo and geo[e.id][1] in ("v", "h") and len(geo[e.id][0]) == 2:
|
|
218
|
+
buckets.setdefault(getattr(e, key), []).append(e)
|
|
219
|
+
for es in buckets.values():
|
|
220
|
+
if len(es) < 2 or any(e.id in self.bend_level for e in es):
|
|
221
|
+
continue
|
|
222
|
+
axis = geo[es[0].id][1]
|
|
223
|
+
i = 1 if axis == "v" else 0
|
|
224
|
+
hub = [geo[e.id][0][-1 if key == "dst" else 0][i] for e in es]
|
|
225
|
+
far = [geo[e.id][0][0 if key == "dst" else -1][i] for e in es]
|
|
226
|
+
if len({round(h) for h in hub}) != 1 or len({geo[e.id][1] for e in es}) != 1:
|
|
227
|
+
continue
|
|
228
|
+
chans = {self.channel.get(e.id) for e in es}
|
|
229
|
+
if len(chans) == 1 and None not in chans:
|
|
230
|
+
lo, hi = chans.pop()
|
|
231
|
+
level = (lo + hi) / 2
|
|
232
|
+
else:
|
|
233
|
+
h = hub[0]
|
|
234
|
+
nearest = min(far, key=lambda f: abs(f - h))
|
|
235
|
+
level = h + (nearest - h) / 2
|
|
236
|
+
for e in es:
|
|
237
|
+
self.bend_level[e.id] = level
|
|
238
|
+
|
|
239
|
+
def _separate_channels(self):
|
|
240
|
+
"""Distinct edges whose sideways runs land on the same level and overlap get their
|
|
241
|
+
own lanes. Edges sharing a source or target *and* the same look stay merged (bus).
|
|
242
|
+
Channels too narrow for their lanes are reported in `self.needs`."""
|
|
243
|
+
runs = []
|
|
244
|
+
for e in self.doc.edges:
|
|
245
|
+
if e.style.get("route", "curve") not in ("bend", "ortho") or e.attrs.get("exit") or e.attrs.get("enter"):
|
|
246
|
+
continue
|
|
247
|
+
pts, axis = self.geometry(e)
|
|
248
|
+
if axis not in ("v", "h") or len(pts) != 2:
|
|
249
|
+
continue
|
|
250
|
+
(x0, y0), (x1, y1) = pts
|
|
251
|
+
v = axis == "v"
|
|
252
|
+
a0, a1 = (y0, y1) if v else (x0, x1)
|
|
253
|
+
c0, c1 = (x0, x1) if v else (y0, y1)
|
|
254
|
+
if abs(c1 - c0) < 0.5:
|
|
255
|
+
continue
|
|
256
|
+
lvl = self.level(e)
|
|
257
|
+
runs.append({"e": e, "axis": axis, "level": (a0 + a1) / 2 if lvl is None else lvl,
|
|
258
|
+
"lo": min(c0, c1), "hi": max(c0, c1), "c0": c0, "c1": c1, "a0": a0, "a1": a1})
|
|
259
|
+
lanes_at: dict[tuple[str, int], int] = {}
|
|
260
|
+
for r in runs:
|
|
261
|
+
gk = self.gapkey.get(r["e"].id)
|
|
262
|
+
if gk:
|
|
263
|
+
lanes_at[gk] = max(lanes_at.get(gk, 0), 1)
|
|
264
|
+
|
|
265
|
+
def look(ed):
|
|
266
|
+
return (ed.body, ed.style.get("stroke-dasharray"), ed.style.get("stroke"), tuple(sorted(ed.classes)))
|
|
267
|
+
|
|
268
|
+
runs.sort(key=lambda r: (r["axis"], r["level"]))
|
|
269
|
+
used: set[int] = set()
|
|
270
|
+
for i, r in enumerate(runs):
|
|
271
|
+
if i in used:
|
|
272
|
+
continue
|
|
273
|
+
cl = [i]
|
|
274
|
+
for j in range(i + 1, len(runs)):
|
|
275
|
+
s = runs[j]
|
|
276
|
+
if j in used or s["axis"] != r["axis"] or abs(s["level"] - r["level"]) > LANE:
|
|
277
|
+
continue
|
|
278
|
+
if any(min(s["hi"], runs[k]["hi"]) - max(s["lo"], runs[k]["lo"]) > 1 for k in cl):
|
|
279
|
+
cl.append(j)
|
|
280
|
+
if len(cl) < 2:
|
|
281
|
+
continue
|
|
282
|
+
lanes: list[list[int]] = []
|
|
283
|
+
for k in cl:
|
|
284
|
+
ek = runs[k]["e"]
|
|
285
|
+
for ln in lanes:
|
|
286
|
+
if any((runs[m]["e"].src == ek.src or runs[m]["e"].dst == ek.dst)
|
|
287
|
+
and look(runs[m]["e"]) == look(ek) for m in ln):
|
|
288
|
+
ln.append(k)
|
|
289
|
+
break
|
|
290
|
+
else:
|
|
291
|
+
lanes.append([k])
|
|
292
|
+
if len(lanes) < 2:
|
|
293
|
+
continue
|
|
294
|
+
used.update(cl)
|
|
295
|
+
# order lanes so runs don't cross: the run that starts further along its
|
|
296
|
+
# travel direction turns later (further from the source rank)
|
|
297
|
+
lanes.sort(key=lambda ln: sum(runs[m]["c0"] - runs[m]["c1"] for m in ln) / len(ln))
|
|
298
|
+
base = sum(runs[m]["level"] for m in cl) / len(cl)
|
|
299
|
+
n = len(lanes)
|
|
300
|
+
for idx, ln in enumerate(lanes):
|
|
301
|
+
lvl = base + (idx - (n - 1) / 2) * LANE
|
|
302
|
+
for m in ln:
|
|
303
|
+
rr = runs[m]
|
|
304
|
+
eid = rr["e"].id
|
|
305
|
+
ch = self.channel.get(eid)
|
|
306
|
+
if ch and ch[1] - ch[0] >= 2 * CLEAR:
|
|
307
|
+
lvl_c = min(max(lvl, ch[0] + CLEAR), ch[1] - CLEAR)
|
|
308
|
+
else:
|
|
309
|
+
lo_a, hi_a = sorted((rr["a0"], rr["a1"]))
|
|
310
|
+
lvl_c = min(max(lvl, lo_a + 4), hi_a - 4)
|
|
311
|
+
self.bend_level[eid] = lvl_c
|
|
312
|
+
gk = self.gapkey.get(eid)
|
|
313
|
+
if gk:
|
|
314
|
+
lanes_at[gk] = max(lanes_at[gk], n)
|
|
315
|
+
# report channels that are too narrow for the lanes they carry
|
|
316
|
+
for e in self.doc.edges:
|
|
317
|
+
gk = self.gapkey.get(e.id)
|
|
318
|
+
if not gk or gk not in lanes_at:
|
|
319
|
+
continue
|
|
320
|
+
lo, hi = self.channel[e.id]
|
|
321
|
+
need = (lanes_at[gk] - 1) * LANE + 2 * CLEAR - (hi - lo)
|
|
322
|
+
if need > 0.5:
|
|
323
|
+
self.needs[gk] = max(self.needs.get(gk, 0.0), need)
|
|
324
|
+
|
|
325
|
+
# ------------------------------------------------------------ geometry
|
|
326
|
+
def _ports(self, a, b, vert):
|
|
327
|
+
if vert:
|
|
328
|
+
down = b.cy > a.cy
|
|
329
|
+
return port(a, "bottom" if down else "top"), port(b, "top" if down else "bottom")
|
|
330
|
+
right = b.cx > a.cx
|
|
331
|
+
return port(a, "right" if right else "left"), port(b, "left" if right else "right")
|
|
332
|
+
|
|
333
|
+
def _record_channel(self, e, owner, da, db, vert):
|
|
334
|
+
"""Free band between the two outer boxes on the flow axis (where runs belong)."""
|
|
335
|
+
if vert:
|
|
336
|
+
lo, hi = (da.ay + da.h, db.ay) if db.ay >= da.ay + da.h else (db.ay + db.h, da.ay)
|
|
337
|
+
else:
|
|
338
|
+
lo, hi = (da.ax + da.w, db.ax) if db.ax >= da.ax + da.w else (db.ax + db.w, da.ax)
|
|
339
|
+
self.channel[e.id] = (lo, hi)
|
|
340
|
+
ri = getattr(getattr(owner, "layout_obj", None), "rank_index", {})
|
|
341
|
+
if da.id in ri and db.id in ri and abs(ri[da.id] - ri[db.id]) == 1:
|
|
342
|
+
self.gapkey[e.id] = (owner.id, min(ri[da.id], ri[db.id]))
|
|
343
|
+
|
|
344
|
+
def geometry(self, e: Edge):
|
|
345
|
+
"""-> (points, axis). axis: v | h (flow axis), L (explicit L port) or free."""
|
|
346
|
+
a, b = self.doc.items[e.src], self.doc.items[e.dst]
|
|
347
|
+
owner = lca(a, b, self.doc.root)
|
|
348
|
+
da, db = direct_child(a, owner), direct_child(b, owner)
|
|
349
|
+
d = flow_dir(owner)
|
|
350
|
+
route = e.style.get("route", "curve")
|
|
351
|
+
wps = list(e.waypoints)
|
|
352
|
+
off = self.offset.get(e.id, 0.0)
|
|
353
|
+
ex, en = e.attrs.get("exit"), e.attrs.get("enter")
|
|
354
|
+
if ex or en:
|
|
355
|
+
ex = ex or ("right" if b.cx > a.cx else "left")
|
|
356
|
+
en = en or ("left" if b.cx > a.cx else "right")
|
|
357
|
+
p0, p1 = port(a, ex), port(b, en)
|
|
358
|
+
v0, v1 = ex in ("top", "bottom"), en in ("top", "bottom")
|
|
359
|
+
if v0 != v1: # L-shape: one corner
|
|
360
|
+
corner = (p0[0], p1[1]) if v0 else (p1[0], p0[1])
|
|
361
|
+
return [p0, corner, p1], "L"
|
|
362
|
+
return [p0] + wps + [p1], ("v" if v0 else "h")
|
|
363
|
+
if d in ("TB", "BT", "LR", "RL") and route != "straight":
|
|
364
|
+
vert = d in ("TB", "BT")
|
|
365
|
+
if vert:
|
|
366
|
+
sep = da.ay + da.h <= db.ay or db.ay + db.h <= da.ay
|
|
367
|
+
else:
|
|
368
|
+
sep = da.ax + da.w <= db.ax or db.ax + db.w <= da.ax
|
|
369
|
+
if not sep:
|
|
370
|
+
vert = not vert # same rank: connect sideways
|
|
371
|
+
elif not wps:
|
|
372
|
+
self._record_channel(e, owner, da, db, vert)
|
|
373
|
+
p0, p1 = self._ports(a, b, vert)
|
|
374
|
+
s0, s1 = self.port_shift.get((e.id, 0), 0.0), self.port_shift.get((e.id, 1), 0.0)
|
|
375
|
+
if vert:
|
|
376
|
+
p0, p1 = (p0[0] + s0, p0[1]), (p1[0] + s1, p1[1])
|
|
377
|
+
else:
|
|
378
|
+
p0, p1 = (p0[0], p0[1] + s0), (p1[0], p1[1] + s1)
|
|
379
|
+
if s0 or s1:
|
|
380
|
+
return [p0] + wps + [p1], ("v" if vert else "h")
|
|
381
|
+
if off:
|
|
382
|
+
if vert:
|
|
383
|
+
p0, p1 = (p0[0] + off, p0[1]), (p1[0] + off, p1[1])
|
|
384
|
+
else:
|
|
385
|
+
p0, p1 = (p0[0], p0[1] + off), (p1[0], p1[1] + off)
|
|
386
|
+
# boxes overlap on the cross axis: one straight line, no jog / double trace
|
|
387
|
+
boxy = all(x.attrs.get("shape", "box") in RECT_SHAPES or isinstance(x, Group) for x in (a, b))
|
|
388
|
+
if not wps and not off and boxy:
|
|
389
|
+
i = 0 if vert else 1
|
|
390
|
+
lo = max(a.ax if vert else a.ay, b.ax if vert else b.ay)
|
|
391
|
+
hi = min((a.ax + a.w) if vert else (a.ay + a.h), (b.ax + b.w) if vert else (b.ay + b.h))
|
|
392
|
+
if abs(p0[i] - p1[i]) > 0.5 and hi - lo >= 16:
|
|
393
|
+
m = min(max((p0[i] + p1[i]) / 2, lo + 8), hi - 8)
|
|
394
|
+
p0 = (m, p0[1]) if vert else (p0[0], m)
|
|
395
|
+
p1 = (m, p1[1]) if vert else (p1[0], m)
|
|
396
|
+
return [p0] + wps + [p1], ("v" if vert else "h")
|
|
397
|
+
# free layouts: shortest centre-to-centre, clipped to outline
|
|
398
|
+
tgt0 = wps[0] if wps else (b.cx, b.cy)
|
|
399
|
+
tgt1 = wps[-1] if wps else (a.cx, a.cy)
|
|
400
|
+
p0 = boundary_point(a, *tgt0, a.attrs.get("shape", "box"))
|
|
401
|
+
p1 = boundary_point(b, *tgt1, b.attrs.get("shape", "box"))
|
|
402
|
+
return [p0] + wps + [p1], ("v" if abs(p1[1] - p0[1]) >= abs(p1[0] - p0[0]) else "h")
|
|
403
|
+
|
|
404
|
+
# ------------------------------------------------------------ paths
|
|
405
|
+
def polyline(self, pts, axis, route, radius, stub, mid, diag: Diagonal):
|
|
406
|
+
"""Corner points of the drawn path (curves: just the anchor points)."""
|
|
407
|
+
if axis == "L" or route == "straight" or len(pts) < 2:
|
|
408
|
+
return list(pts)
|
|
409
|
+
if route in ("bend", "ortho") and len(pts) == 2 and diag.mode != "legacy":
|
|
410
|
+
return octilinear(pts[0], pts[-1], axis, mid, stub if stub is not None else radius * 1.5, diag)
|
|
411
|
+
if route == "bend":
|
|
412
|
+
(x0, y0), (x1, y1) = pts[0], pts[-1]
|
|
413
|
+
span = abs(y1 - y0) if axis == "v" else abs(x1 - x0)
|
|
414
|
+
k = min(stub if stub is not None else radius * 1.5, span / 2)
|
|
415
|
+
sy = math.copysign(k, (y1 - y0) or 1)
|
|
416
|
+
sx = math.copysign(k, (x1 - x0) or 1)
|
|
417
|
+
inner = list(pts[1:-1])
|
|
418
|
+
if axis == "v":
|
|
419
|
+
return [pts[0], (x0, y0 + sy)] + inner + [(x1, y1 - sy), pts[-1]]
|
|
420
|
+
return [pts[0], (x0 + sx, y0)] + inner + [(x1 - sx, y1), pts[-1]]
|
|
421
|
+
if route == "ortho":
|
|
422
|
+
seg = [pts[0]]
|
|
423
|
+
for (x0, y0), (x1, y1) in zip(pts, pts[1:]):
|
|
424
|
+
if axis == "v":
|
|
425
|
+
my = mid if mid is not None and len(pts) == 2 else (y0 + y1) / 2
|
|
426
|
+
seg += [(x0, my), (x1, my), (x1, y1)]
|
|
427
|
+
else:
|
|
428
|
+
mx = mid if mid is not None and len(pts) == 2 else (x0 + x1) / 2
|
|
429
|
+
seg += [(mx, y0), (mx, y1), (x1, y1)]
|
|
430
|
+
clean = [seg[0]]
|
|
431
|
+
for p in seg[1:]:
|
|
432
|
+
if abs(p[0] - clean[-1][0]) > 0.05 or abs(p[1] - clean[-1][1]) > 0.05:
|
|
433
|
+
clean.append(p)
|
|
434
|
+
return clean
|
|
435
|
+
return list(pts)
|
|
436
|
+
|
|
437
|
+
def path_segs(self, pts, axis, route, radius, stub, mid, diag: Diagonal):
|
|
438
|
+
if axis == "L":
|
|
439
|
+
return rounded(list(pts), max(radius, 8))
|
|
440
|
+
if route == "straight" or len(pts) < 2:
|
|
441
|
+
return rounded(list(pts), 0)
|
|
442
|
+
if route in ("bend", "ortho"):
|
|
443
|
+
return rounded(self.polyline(pts, axis, route, radius, stub, mid, diag), radius)
|
|
444
|
+
# curve: cubic beziers with tangents along the flow axis
|
|
445
|
+
segs = [("M", *pts[0])]
|
|
446
|
+
for (x0, y0), (x1, y1) in zip(pts, pts[1:]):
|
|
447
|
+
if axis == "v":
|
|
448
|
+
k = (y1 - y0) / 2
|
|
449
|
+
segs.append(("C", x0, y0 + k, x1, y1 - k, x1, y1))
|
|
450
|
+
else:
|
|
451
|
+
k = (x1 - x0) / 2
|
|
452
|
+
segs.append(("C", x0 + k, y0, x1 - k, y1, x1, y1))
|
|
453
|
+
return segs
|
predictygraph/safe.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 The predictygraph Authors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
"""Input hardening shared by the parser, styles, text and animation code."""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
NUM = r"-?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?"
|
|
12
|
+
NUM_RE = re.compile(NUM)
|
|
13
|
+
MAX_NUM = 1e6
|
|
14
|
+
MAX_EDGES = 10_000
|
|
15
|
+
MAX_VAR_LEN = 10_000
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class UnsafePathError(ValueError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def number(v, default: float = 0.0, lo: float = -MAX_NUM, hi: float = MAX_NUM) -> float:
|
|
23
|
+
"""Leading CSS-ish number of v, clamped to [lo, hi]; default when absent or not finite."""
|
|
24
|
+
m = re.match(rf"\s*({NUM})", str(v)) if v is not None else None
|
|
25
|
+
if not m:
|
|
26
|
+
return default
|
|
27
|
+
n = float(m.group(1))
|
|
28
|
+
return default if n != n else min(hi, max(lo, n)) # NaN check
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def numbers(v) -> list[float]:
|
|
32
|
+
return [min(MAX_NUM, max(-MAX_NUM, float(x))) for x in NUM_RE.findall(v or "")]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def safe_path(base_dir: str, rel: str, allow_outside: bool = False) -> Path:
|
|
36
|
+
"""Resolve rel against base_dir; refuse anything that escapes base_dir unless allowed."""
|
|
37
|
+
base = Path(base_dir).resolve()
|
|
38
|
+
p = (base / rel).resolve()
|
|
39
|
+
if not allow_outside and not p.is_relative_to(base):
|
|
40
|
+
raise UnsafePathError(f"path {rel!r} is outside {str(base)!r} (use --allow-outside)")
|
|
41
|
+
return p
|