codefig 0.17.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.
- codefig/__init__.py +210 -0
- codefig/_validate.py +12 -0
- codefig/autowrap.py +277 -0
- codefig/charts.py +1806 -0
- codefig/code.py +602 -0
- codefig/compose.py +374 -0
- codefig/constraints.py +1571 -0
- codefig/dim.py +214 -0
- codefig/edge_align.py +231 -0
- codefig/edges.py +2824 -0
- codefig/export.py +204 -0
- codefig/figure.py +1923 -0
- codefig/fills.py +146 -0
- codefig/flows.py +102 -0
- codefig/glyphs.py +395 -0
- codefig/icon.py +288 -0
- codefig/icons/LICENSE-tabler-icons.txt +21 -0
- codefig/icons/__init__.py +101 -0
- codefig/icons/tabler.json +1 -0
- codefig/legend.py +483 -0
- codefig/lint.py +1737 -0
- codefig/lint_geom.py +528 -0
- codefig/node.py +864 -0
- codefig/notices.py +34 -0
- codefig/overlaps.py +392 -0
- codefig/policy.py +137 -0
- codefig/primitives.py +2721 -0
- codefig/py.typed +0 -0
- codefig/regions.py +727 -0
- codefig/render_svg.py +512 -0
- codefig/routing.py +932 -0
- codefig/shapes.py +642 -0
- codefig/solids.py +514 -0
- codefig/steps.py +203 -0
- codefig/styles.py +893 -0
- codefig/textutil.py +889 -0
- codefig/timeline.py +1288 -0
- codefig/tree.py +277 -0
- codefig/venues.py +93 -0
- codefig-0.17.0.dist-info/METADATA +542 -0
- codefig-0.17.0.dist-info/RECORD +44 -0
- codefig-0.17.0.dist-info/WHEEL +5 -0
- codefig-0.17.0.dist-info/licenses/LICENSE +21 -0
- codefig-0.17.0.dist-info/top_level.txt +1 -0
codefig/edges.py
ADDED
|
@@ -0,0 +1,2824 @@
|
|
|
1
|
+
"""Port anchors, styled Edges, Diagram overlay: lanes, obstacles, bundling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
|
8
|
+
|
|
9
|
+
from .constraints import ConstraintScope
|
|
10
|
+
from .node import BoxGeom, Node
|
|
11
|
+
from .styles import (
|
|
12
|
+
DEFAULT_THEME,
|
|
13
|
+
current_theme,
|
|
14
|
+
themed,
|
|
15
|
+
ResolvedEdgeStyle,
|
|
16
|
+
Theme,
|
|
17
|
+
resolve_edge_style,
|
|
18
|
+
)
|
|
19
|
+
from .textutil import measure_text
|
|
20
|
+
|
|
21
|
+
Side = str # left|right|top|bottom|center
|
|
22
|
+
Pt = Tuple[float, float]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _stub(x: float, y: float, direction: str, length: float) -> Pt:
|
|
26
|
+
if direction == "right":
|
|
27
|
+
return x + length, y
|
|
28
|
+
if direction == "left":
|
|
29
|
+
return x - length, y
|
|
30
|
+
if direction == "down":
|
|
31
|
+
return x, y + length
|
|
32
|
+
if direction == "up":
|
|
33
|
+
return x, y - length
|
|
34
|
+
return x + length, y
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Port:
|
|
39
|
+
"""Symbolic anchor on a laid-out node (no absolute author coordinates)."""
|
|
40
|
+
|
|
41
|
+
node: Node
|
|
42
|
+
kind: str # side | row | col | cell | cell_range | custom
|
|
43
|
+
side: Side = "right"
|
|
44
|
+
index: int = 0
|
|
45
|
+
col_index: int = 0
|
|
46
|
+
end_index: int = 0
|
|
47
|
+
at: float = 0.5
|
|
48
|
+
align_ref: Any = None
|
|
49
|
+
snap: bool = False
|
|
50
|
+
offset: float = 0.0
|
|
51
|
+
slide: float = 0.0 # v0.11.0: an explicit at= may move by up to this much
|
|
52
|
+
# v0.13.0: Edge(parallel=N) — leg i of n, spread across the overlap with ``peer``
|
|
53
|
+
peer: Any = None
|
|
54
|
+
par_i: int = 0
|
|
55
|
+
par_n: int = 1
|
|
56
|
+
par_gap: Optional[float] = None
|
|
57
|
+
|
|
58
|
+
def resolve(self) -> Tuple[float, float, str]:
|
|
59
|
+
x, y, d = self._resolve()
|
|
60
|
+
if self.offset and self.kind in ("side", "custom"):
|
|
61
|
+
# v0.13.0: offset= shifts a side port along its side (pt), e.g. three
|
|
62
|
+
# parallel arrows port("top", offset=-6/0/6)
|
|
63
|
+
if d in ("up", "down"):
|
|
64
|
+
x += self.offset
|
|
65
|
+
else:
|
|
66
|
+
y += self.offset
|
|
67
|
+
return x, y, d
|
|
68
|
+
|
|
69
|
+
def _resolve(self) -> Tuple[float, float, str]:
|
|
70
|
+
node = self.node
|
|
71
|
+
if self.kind == "parallel":
|
|
72
|
+
return resolve_parallel_port(self)
|
|
73
|
+
if self.kind == "aligned":
|
|
74
|
+
return on_outline(node, self.side, resolve_aligned_port(self))
|
|
75
|
+
if self.kind == "side":
|
|
76
|
+
if self.side == "auto": # resolved against the other end by Edge.endpoints
|
|
77
|
+
g = node.geom
|
|
78
|
+
return g.cx, g.cy, "right"
|
|
79
|
+
custom = getattr(node, "resolve_port_side", None)
|
|
80
|
+
if custom is not None: # e.g. Stacked: in line with the front card
|
|
81
|
+
return custom(self.side, self.at)
|
|
82
|
+
return on_outline(node, self.side, resolve_side_port(node, self.side, self.at))
|
|
83
|
+
if self.kind == "row" and hasattr(node, "resolve_row_port"):
|
|
84
|
+
return node.resolve_row_port(self.index, self.side) # type: ignore[attr-defined]
|
|
85
|
+
if self.kind == "col" and hasattr(node, "resolve_col_port"):
|
|
86
|
+
return node.resolve_col_port(self.index, self.side) # type: ignore[attr-defined]
|
|
87
|
+
if self.kind == "cell" and hasattr(node, "resolve_cell_port"):
|
|
88
|
+
return node.resolve_cell_port(self.index, self.col_index, self.side) # type: ignore[attr-defined]
|
|
89
|
+
if self.kind == "custom" and hasattr(node, "resolve_custom_port"):
|
|
90
|
+
# v0.7.0: node-defined anchors (Cuboid faces, Pyramid levels, …); the
|
|
91
|
+
# name travels in ``side`` and an optional integer in ``index``.
|
|
92
|
+
return node.resolve_custom_port(self.side, self.index, self.at) # type: ignore[attr-defined]
|
|
93
|
+
if self.kind == "cell_range" and hasattr(node, "resolve_cell_range_port"):
|
|
94
|
+
return node.resolve_cell_range_port(self.index, self.end_index, self.side) # type: ignore[attr-defined]
|
|
95
|
+
return resolve_side_port(node, self.side, self.at)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _side_towards(g: Any, x: float, y: float, wside: str = "top") -> str:
|
|
99
|
+
"""Side of box ``g`` that faces the waypoint (x, y) on side ``wside`` of its node
|
|
100
|
+
(v0.14.0): a route passing over / under a node leaves vertically when it can, one
|
|
101
|
+
passing beside a node leaves sideways."""
|
|
102
|
+
vert = ("bottom" if y >= g.bottom else "top" if y <= g.y else None)
|
|
103
|
+
horiz = ("right" if x >= g.right else "left" if x <= g.x else None)
|
|
104
|
+
if wside in ("top", "bottom"):
|
|
105
|
+
return vert or horiz or "right"
|
|
106
|
+
return horiz or vert or "bottom"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _facing_side(node: Node, other: Node) -> str:
|
|
110
|
+
g, o = node.geom, other.geom
|
|
111
|
+
hgap = max(o.x - g.right, g.x - o.right)
|
|
112
|
+
vgap = max(o.y - g.bottom, g.y - o.bottom)
|
|
113
|
+
if vgap >= hgap:
|
|
114
|
+
return "bottom" if o.cy >= g.cy else "top"
|
|
115
|
+
return "right" if o.cx >= g.cx else "left"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def resolve_parallel_port(port: "Port") -> Tuple[float, float, str]:
|
|
119
|
+
"""v0.13.0: end ``par_i`` of ``par_n`` parallel arrows between two nodes.
|
|
120
|
+
|
|
121
|
+
The arrows are spread evenly (``par_gap`` pt apart, default ≈ 6 pt, tighter when the
|
|
122
|
+
sides are short) around the middle of the range the two facing sides share, so they
|
|
123
|
+
run straight; without such an overlap each end spreads around its own side centre.
|
|
124
|
+
"""
|
|
125
|
+
node, other = port.node, port.peer
|
|
126
|
+
side = port.side if port.side in ("left", "right", "top", "bottom") else _facing_side(node, other)
|
|
127
|
+
oside = {"left": "right", "right": "left", "top": "bottom", "bottom": "top"}[side]
|
|
128
|
+
x, y, w, h = _side_extent(node, side)
|
|
129
|
+
ox, oy, ow, oh = _side_extent(other, oside)
|
|
130
|
+
horiz = side in ("top", "bottom")
|
|
131
|
+
lo, hi = (x, x + w) if horiz else (y, y + h)
|
|
132
|
+
olo, ohi = (ox, ox + ow) if horiz else (oy, oy + oh)
|
|
133
|
+
n = max(1, int(port.par_n))
|
|
134
|
+
th = getattr(node, "theme", None)
|
|
135
|
+
L = th.L if th is not None else (lambda v: v)
|
|
136
|
+
m = L(2.0)
|
|
137
|
+
span = min(hi - lo, ohi - olo)
|
|
138
|
+
a, b = max(lo, olo), min(hi, ohi)
|
|
139
|
+
if port.par_gap is not None:
|
|
140
|
+
gap = port.par_gap
|
|
141
|
+
else: # spread over the shared range, 4–10 pt apart
|
|
142
|
+
gap = min(L(10.0), max(L(4.0), (max(b - a, 0.0) if b - a > 2 * m else span) / (n + 1)))
|
|
143
|
+
if n > 1:
|
|
144
|
+
gap = min(gap, max(0.5, (span - 2 * m) / (n - 1)))
|
|
145
|
+
need = gap * (n - 1)
|
|
146
|
+
centre = (a + b) / 2 if b - a >= need + 2 * m else (lo + hi) / 2
|
|
147
|
+
v = centre + (port.par_i - (n - 1) / 2) * gap
|
|
148
|
+
v = min(max(v, lo), hi)
|
|
149
|
+
if side == "top":
|
|
150
|
+
pt = (v, y, "up")
|
|
151
|
+
elif side == "bottom":
|
|
152
|
+
pt = (v, y + h, "down")
|
|
153
|
+
elif side == "left":
|
|
154
|
+
pt = (x, v, "left")
|
|
155
|
+
else:
|
|
156
|
+
pt = (x + w, v, "right")
|
|
157
|
+
return on_outline(node, side, pt)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def resolve_side_port(node: Node, side: Side, at: float = 0.5) -> Tuple[float, float, str]:
|
|
161
|
+
g = node.geom
|
|
162
|
+
at = max(0.0, min(1.0, at))
|
|
163
|
+
if side == "right":
|
|
164
|
+
return g.right, g.y + g.h * at, "right"
|
|
165
|
+
if side == "left":
|
|
166
|
+
return g.x, g.y + g.h * at, "left"
|
|
167
|
+
if side == "top":
|
|
168
|
+
return g.x + g.w * at, g.y, "up"
|
|
169
|
+
if side == "bottom":
|
|
170
|
+
return g.x + g.w * at, g.bottom, "down"
|
|
171
|
+
return g.cx, g.cy, "right"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def on_outline(node: Node, side: str, pt: Tuple[float, float, str]) -> Tuple[float, float, str]:
|
|
175
|
+
"""Move a bounding-box port onto a shape's true outline (shapes.Shape)."""
|
|
176
|
+
if side in ("top", "bottom", "left", "right") and hasattr(node, "outline_point"):
|
|
177
|
+
x, y = node.outline_point(side, pt[0], pt[1]) # type: ignore[attr-defined]
|
|
178
|
+
return x, y, pt[2]
|
|
179
|
+
return pt
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def auto_port(node: Node, tx: float, ty: float) -> Tuple[float, float, str]:
|
|
183
|
+
"""Point on ``node``'s outline facing (tx, ty); direction = dominant axis."""
|
|
184
|
+
g = node.geom
|
|
185
|
+
if hasattr(node, "ray_point"):
|
|
186
|
+
tr = node._text_rect(g.x, g.y, g.w, g.h) # type: ignore[attr-defined]
|
|
187
|
+
cx, cy = tr[0] + tr[2] / 2, tr[1] + tr[3] / 2
|
|
188
|
+
else:
|
|
189
|
+
cx, cy = g.cx, g.cy
|
|
190
|
+
dx, dy = tx - cx, ty - cy
|
|
191
|
+
if abs(dx) < 1e-9 and abs(dy) < 1e-9:
|
|
192
|
+
return g.right, g.cy, "right"
|
|
193
|
+
if hasattr(node, "ray_point"):
|
|
194
|
+
x, y = node.ray_point(dx, dy) # type: ignore[attr-defined]
|
|
195
|
+
else:
|
|
196
|
+
from .shapes import ray_hit
|
|
197
|
+
|
|
198
|
+
x, y = ray_hit([(g.x, g.y), (g.right, g.y), (g.right, g.bottom), (g.x, g.bottom)],
|
|
199
|
+
cx, cy, dx, dy)
|
|
200
|
+
if abs(dx) >= abs(dy):
|
|
201
|
+
d = "right" if dx > 0 else "left"
|
|
202
|
+
else:
|
|
203
|
+
d = "down" if dy > 0 else "up"
|
|
204
|
+
return x, y, d
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _auto_in_line(node: Node, other: Tuple[float, float, str]) -> Optional[Tuple[float, float, str]]:
|
|
208
|
+
"""v0.13.0: an ``auto`` end facing a fixed side port lines up with it when it can.
|
|
209
|
+
|
|
210
|
+
When the fixed end points straight at a rectangular ``node`` and its x (or y) falls
|
|
211
|
+
inside the node's extent, the auto end sits on the facing side *in line* with it, so
|
|
212
|
+
the connector is one straight segment instead of a ray-to-centre jog (e.g. several
|
|
213
|
+
``Edge(p_i, gpu_i, via=loader)`` through one tall box stay straight).
|
|
214
|
+
"""
|
|
215
|
+
if hasattr(node, "ray_point"):
|
|
216
|
+
return None
|
|
217
|
+
g = node.geom
|
|
218
|
+
x, y, d = other
|
|
219
|
+
m = 1.0
|
|
220
|
+
if d == "right" and x <= g.x + 1e-6 and g.y + m <= y <= g.bottom - m:
|
|
221
|
+
return g.x, y, "left"
|
|
222
|
+
if d == "left" and x >= g.right - 1e-6 and g.y + m <= y <= g.bottom - m:
|
|
223
|
+
return g.right, y, "right"
|
|
224
|
+
if d == "down" and y <= g.y + 1e-6 and g.x + m <= x <= g.right - m:
|
|
225
|
+
return x, g.y, "up"
|
|
226
|
+
if d == "up" and y >= g.bottom - 1e-6 and g.x + m <= x <= g.right - m:
|
|
227
|
+
return x, g.bottom, "down"
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _side_extent(node: Node, side: Side) -> Tuple[float, float, float, float]:
|
|
232
|
+
"""Box (x, y, w, h) whose side a port sits on — a Matrix uses its cell grid."""
|
|
233
|
+
if hasattr(node, "_grid_origin") and hasattr(node, "_grid_size"):
|
|
234
|
+
gx, gy = node._grid_origin() # type: ignore[attr-defined]
|
|
235
|
+
gw, gh = node._grid_size() # type: ignore[attr-defined]
|
|
236
|
+
return gx, gy, gw, gh
|
|
237
|
+
g = node.geom
|
|
238
|
+
return g.x, g.y, g.w, g.h
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def resolve_aligned_port(port: "Port") -> Tuple[float, float, str]:
|
|
242
|
+
"""Port on ``port.side`` of its node, in line with ``port.align_ref``."""
|
|
243
|
+
from .constraints import as_anchor
|
|
244
|
+
|
|
245
|
+
node, side = port.node, port.side
|
|
246
|
+
x, y, w, h = _side_extent(node, side)
|
|
247
|
+
horizontal_side = side in ("top", "bottom")
|
|
248
|
+
axis = "x" if horizontal_side else "y"
|
|
249
|
+
ref = port.align_ref
|
|
250
|
+
v = as_anchor(ref, axis, "center").value() + port.offset
|
|
251
|
+
if horizontal_side:
|
|
252
|
+
v = min(max(v, x), x + w)
|
|
253
|
+
if port.snap and hasattr(node, "col_at"):
|
|
254
|
+
c = node.col_at(v) # type: ignore[attr-defined]
|
|
255
|
+
v = node._col_center_x(c) # type: ignore[attr-defined]
|
|
256
|
+
elif port.snap and hasattr(node, "bar_at"):
|
|
257
|
+
v = node._bar_cx(node.bar_at(v)) # type: ignore[attr-defined]
|
|
258
|
+
if side == "top":
|
|
259
|
+
return v, y, "up"
|
|
260
|
+
return v, y + h, "down"
|
|
261
|
+
v = min(max(v, y), y + h)
|
|
262
|
+
if port.snap and hasattr(node, "row_at"):
|
|
263
|
+
v = node._row_center_y(node.row_at(v)) # type: ignore[attr-defined]
|
|
264
|
+
if side == "left":
|
|
265
|
+
return x, v, "left"
|
|
266
|
+
if side in ("right", "center"):
|
|
267
|
+
return x + w, v, "right"
|
|
268
|
+
return x + w, v, "right"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _end_rects(e: "Edge", x1: float, y1: float, d1: str, x2: float, y2: float,
|
|
272
|
+
d2: str) -> list:
|
|
273
|
+
"""v0.8.0: an edge's own end nodes as obstacles for its trunk.
|
|
274
|
+
|
|
275
|
+
Endpoint nodes are excluded from the obstacle set so the stubs can leave them; without
|
|
276
|
+
this a detour could still cut back through the source or target box. A node is added
|
|
277
|
+
(inset 0.5 pt, no padding) only when its port and stub point both lie outside it.
|
|
278
|
+
"""
|
|
279
|
+
from .routing import ORect
|
|
280
|
+
|
|
281
|
+
out = []
|
|
282
|
+
stub = _facing_stub(x1, y1, d1, x2, y2, d2, e.stub)
|
|
283
|
+
for node, (x, y, d) in ((e.src.node, (x1, y1, d1)), (e.dst.node, (x2, y2, d2))):
|
|
284
|
+
g = getattr(node, "geom", None)
|
|
285
|
+
if g is None or g.w < 2 or g.h < 2:
|
|
286
|
+
continue
|
|
287
|
+
r = (g.x + 0.5, g.y + 0.5, g.w - 1.0, g.h - 1.0)
|
|
288
|
+
sx, sy = _stub(x, y, d, stub)
|
|
289
|
+
|
|
290
|
+
def inside(px: float, py: float) -> bool:
|
|
291
|
+
return r[0] < px < r[0] + r[2] and r[1] < py < r[1] + r[3]
|
|
292
|
+
|
|
293
|
+
if not inside(x, y) and not inside(sx, sy):
|
|
294
|
+
out.append(ORect(*r, role="hard", pad=0.0))
|
|
295
|
+
return out
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def route_straight(
|
|
299
|
+
x1: float, y1: float, d1: str, x2: float, y2: float, d2: str, stub: float = 0.0
|
|
300
|
+
) -> List[Pt]:
|
|
301
|
+
if stub <= 0:
|
|
302
|
+
return [(x1, y1), (x2, y2)]
|
|
303
|
+
ax, ay = _stub(x1, y1, d1, stub)
|
|
304
|
+
bx, by = _stub(x2, y2, d2, stub)
|
|
305
|
+
return [(x1, y1), (ax, ay), (bx, by), (x2, y2)]
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _facing_stub(x1: float, y1: float, d1: str, x2: float, y2: float, d2: str,
|
|
309
|
+
stub: float) -> float:
|
|
310
|
+
"""Stub length clamped to half the gap between two facing ports."""
|
|
311
|
+
gap = {("down", "up"): y2 - y1, ("up", "down"): y1 - y2,
|
|
312
|
+
("right", "left"): x2 - x1, ("left", "right"): x1 - x2}.get((d1, d2))
|
|
313
|
+
if gap is not None and 0.0 < gap < 2.0 * stub:
|
|
314
|
+
return gap / 2.0
|
|
315
|
+
return stub
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _trunk0(a: float, b: float, d1: str, d2: str) -> float:
|
|
319
|
+
"""Natural trunk coordinate between two stub ends ``a`` and ``b``.
|
|
320
|
+
|
|
321
|
+
Opposite or facing ports meet half way. Two ports facing the *same* way (both
|
|
322
|
+
``left``, a C-shaped route) need the trunk outside both stubs: half way would make
|
|
323
|
+
the nearer stub double back on itself (v0.11.0)."""
|
|
324
|
+
if d1 == d2 and d1 in ("left", "up"):
|
|
325
|
+
return min(a, b)
|
|
326
|
+
if d1 == d2 and d1 in ("right", "down"):
|
|
327
|
+
return max(a, b)
|
|
328
|
+
return (a + b) / 2.0
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def route_ortho(
|
|
332
|
+
x1: float,
|
|
333
|
+
y1: float,
|
|
334
|
+
d1: str,
|
|
335
|
+
x2: float,
|
|
336
|
+
y2: float,
|
|
337
|
+
d2: str,
|
|
338
|
+
stub: float = 12.0,
|
|
339
|
+
mid_bias: float = 0.0,
|
|
340
|
+
) -> List[Pt]:
|
|
341
|
+
"""Manhattan route; mid_bias shifts the shared trunk for lane separation.
|
|
342
|
+
|
|
343
|
+
Facing ports closer than two stubs (a narrow row / column gap) get shorter stubs so
|
|
344
|
+
the trunk stays inside the gap instead of doubling back through the nodes (v0.7.0).
|
|
345
|
+
"""
|
|
346
|
+
stub = _facing_stub(x1, y1, d1, x2, y2, d2, stub)
|
|
347
|
+
ax, ay = _stub(x1, y1, d1, stub)
|
|
348
|
+
bx, by = _stub(x2, y2, d2, stub)
|
|
349
|
+
|
|
350
|
+
horiz_src = d1 in ("left", "right")
|
|
351
|
+
horiz_dst = d2 in ("left", "right")
|
|
352
|
+
|
|
353
|
+
if horiz_src and horiz_dst:
|
|
354
|
+
mid_x = _trunk0(ax, bx, d1, d2) + mid_bias
|
|
355
|
+
pts = [(x1, y1), (ax, ay), (mid_x, ay), (mid_x, by), (bx, by), (x2, y2)]
|
|
356
|
+
elif (not horiz_src) and (not horiz_dst):
|
|
357
|
+
mid_y = _trunk0(ay, by, d1, d2) + mid_bias
|
|
358
|
+
pts = [(x1, y1), (ax, ay), (ax, mid_y), (bx, mid_y), (bx, by), (x2, y2)]
|
|
359
|
+
elif horiz_src:
|
|
360
|
+
pts = [(x1, y1), (ax, ay), (bx + mid_bias, ay), (bx + mid_bias, by), (x2, y2)]
|
|
361
|
+
else:
|
|
362
|
+
pts = [(x1, y1), (ax, ay), (ax, by + mid_bias), (bx, by + mid_bias), (x2, y2)]
|
|
363
|
+
|
|
364
|
+
return _dedupe(pts)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def route_loop(
|
|
369
|
+
x1: float,
|
|
370
|
+
y1: float,
|
|
371
|
+
d1: str,
|
|
372
|
+
x2: float,
|
|
373
|
+
y2: float,
|
|
374
|
+
d2: str,
|
|
375
|
+
*,
|
|
376
|
+
stub: float = 12.0,
|
|
377
|
+
side: str = "auto",
|
|
378
|
+
clearance: float = 40.0,
|
|
379
|
+
) -> List[Pt]:
|
|
380
|
+
"""
|
|
381
|
+
Port-based feedback path that goes around content.
|
|
382
|
+
|
|
383
|
+
Both endpoints typically face the same outer side (e.g. both "right").
|
|
384
|
+
`side`: right|left|top|bottom|auto — which side of the content the loop hugs.
|
|
385
|
+
`clearance`: how far outside the farther port the trunk runs.
|
|
386
|
+
"""
|
|
387
|
+
ax, ay = _stub(x1, y1, d1, stub)
|
|
388
|
+
bx, by = _stub(x2, y2, d2, stub)
|
|
389
|
+
|
|
390
|
+
if side == "auto":
|
|
391
|
+
# Prefer the shared outward direction of the ports.
|
|
392
|
+
if d1 == d2 and d1 in ("left", "right", "up", "down"):
|
|
393
|
+
side = {"right": "right", "left": "left", "up": "top", "down": "bottom"}[d1]
|
|
394
|
+
elif abs(x1 - x2) >= abs(y1 - y2):
|
|
395
|
+
side = "right" if (x1 + x2) / 2 >= 0 else "left"
|
|
396
|
+
else:
|
|
397
|
+
side = "right"
|
|
398
|
+
|
|
399
|
+
if side == "right":
|
|
400
|
+
trunk_x = max(ax, bx, x1, x2) + clearance
|
|
401
|
+
pts = [(x1, y1), (ax, ay), (trunk_x, ay), (trunk_x, by), (bx, by), (x2, y2)]
|
|
402
|
+
elif side == "left":
|
|
403
|
+
trunk_x = min(ax, bx, x1, x2) - clearance
|
|
404
|
+
pts = [(x1, y1), (ax, ay), (trunk_x, ay), (trunk_x, by), (bx, by), (x2, y2)]
|
|
405
|
+
elif side == "bottom":
|
|
406
|
+
trunk_y = max(ay, by, y1, y2) + clearance
|
|
407
|
+
pts = [(x1, y1), (ax, ay), (ax, trunk_y), (bx, trunk_y), (bx, by), (x2, y2)]
|
|
408
|
+
else: # top
|
|
409
|
+
trunk_y = min(ay, by, y1, y2) - clearance
|
|
410
|
+
pts = [(x1, y1), (ax, ay), (ax, trunk_y), (bx, trunk_y), (bx, by), (x2, y2)]
|
|
411
|
+
return _dedupe(pts)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def route_loop_around(x1, y1, d1, x2, y2, d2, edge: "Edge") -> List[Pt]:
|
|
415
|
+
"""Loop whose trunk runs ``clearance`` outside ``edge.around``'s box."""
|
|
416
|
+
g = edge.around.geom
|
|
417
|
+
side = edge.loop_side if edge.loop_side != "auto" else "right"
|
|
418
|
+
c = edge.clearance
|
|
419
|
+
ax, ay = _stub(x1, y1, d1, edge.stub)
|
|
420
|
+
bx, by = _stub(x2, y2, d2, edge.stub)
|
|
421
|
+
if side == "top":
|
|
422
|
+
t = min(g.y - c, ay, by)
|
|
423
|
+
pts = [(x1, y1), (ax, ay), (ax, t), (bx, t), (bx, by), (x2, y2)]
|
|
424
|
+
elif side == "bottom":
|
|
425
|
+
t = max(g.y + g.h + c, ay, by)
|
|
426
|
+
pts = [(x1, y1), (ax, ay), (ax, t), (bx, t), (bx, by), (x2, y2)]
|
|
427
|
+
elif side == "left":
|
|
428
|
+
t = min(g.x - c, ax, bx)
|
|
429
|
+
pts = [(x1, y1), (ax, ay), (t, ay), (t, by), (bx, by), (x2, y2)]
|
|
430
|
+
else:
|
|
431
|
+
t = max(g.x + g.w + c, ax, bx)
|
|
432
|
+
pts = [(x1, y1), (ax, ay), (t, ay), (t, by), (bx, by), (x2, y2)]
|
|
433
|
+
return _dedupe(pts)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _dedupe(pts: List[Pt]) -> List[Pt]:
|
|
437
|
+
out: List[Pt] = []
|
|
438
|
+
for p in pts:
|
|
439
|
+
if not out or abs(out[-1][0] - p[0]) > 0.01 or abs(out[-1][1] - p[1]) > 0.01:
|
|
440
|
+
out.append(p)
|
|
441
|
+
return out
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _simplify_ortho(pts: List[Pt], backtracks_only: bool = False) -> List[Pt]:
|
|
445
|
+
"""Drop duplicate / collinear vertices of a polyline (keeps both ends). Collinear
|
|
446
|
+
backtracks shorter than 0.5 are folded too; longer ones are kept. With
|
|
447
|
+
``backtracks_only`` plain collinear vertices stay (label placement reads them)."""
|
|
448
|
+
out = _dedupe(list(pts))
|
|
449
|
+
changed = True
|
|
450
|
+
while changed and len(out) > 2:
|
|
451
|
+
changed = False
|
|
452
|
+
for i in range(1, len(out) - 1):
|
|
453
|
+
a, b, c = out[i - 1], out[i], out[i + 1]
|
|
454
|
+
col_x = abs(a[0] - b[0]) < 1e-6 and abs(b[0] - c[0]) < 1e-6
|
|
455
|
+
col_y = abs(a[1] - b[1]) < 1e-6 and abs(b[1] - c[1]) < 1e-6
|
|
456
|
+
if not (col_x or col_y):
|
|
457
|
+
continue
|
|
458
|
+
k = 1 if col_x else 0
|
|
459
|
+
back = (b[k] - a[k]) * (c[k] - b[k]) < 0
|
|
460
|
+
if back and min(abs(b[k] - a[k]), abs(c[k] - b[k])) >= 0.5:
|
|
461
|
+
continue
|
|
462
|
+
if backtracks_only and not back:
|
|
463
|
+
continue
|
|
464
|
+
del out[i]
|
|
465
|
+
changed = True
|
|
466
|
+
break
|
|
467
|
+
return out
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _as_port(ref: Union[Port, Node], default_side: Side) -> Port:
|
|
471
|
+
if isinstance(ref, Port):
|
|
472
|
+
return ref
|
|
473
|
+
if isinstance(ref, Node):
|
|
474
|
+
return ref.port(default_side)
|
|
475
|
+
raise TypeError(f"Edge endpoint must be Port or Node, got {type(ref)!r}")
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
# ---- overlap avoidance -------------------------------------------------
|
|
479
|
+
|
|
480
|
+
def _vertical_trunks(pts: List[Pt]) -> List[Tuple[int, float, float, float]]:
|
|
481
|
+
"""Return (seg_index, x, y_min, y_max) for vertical segments longer than epsilon."""
|
|
482
|
+
out = []
|
|
483
|
+
for i in range(len(pts) - 1):
|
|
484
|
+
x1, y1 = pts[i]
|
|
485
|
+
x2, y2 = pts[i + 1]
|
|
486
|
+
if abs(x1 - x2) < 0.51 and abs(y1 - y2) > 1.0:
|
|
487
|
+
out.append((i, (x1 + x2) / 2.0, min(y1, y2), max(y1, y2)))
|
|
488
|
+
return out
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _horizontal_trunks(pts: List[Pt]) -> List[Tuple[int, float, float, float]]:
|
|
492
|
+
out = []
|
|
493
|
+
for i in range(len(pts) - 1):
|
|
494
|
+
x1, y1 = pts[i]
|
|
495
|
+
x2, y2 = pts[i + 1]
|
|
496
|
+
if abs(y1 - y2) < 0.51 and abs(x1 - x2) > 1.0:
|
|
497
|
+
out.append((i, (y1 + y2) / 2.0, min(x1, x2), max(x1, x2)))
|
|
498
|
+
return out
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _ranges_overlap(a0: float, a1: float, b0: float, b1: float, pad: float = 2.0) -> bool:
|
|
502
|
+
return not (a1 + pad < b0 or b1 + pad < a0)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _offset_segment_x(pts: List[Pt], seg_i: int, dx: float) -> List[Pt]:
|
|
506
|
+
pts = list(pts)
|
|
507
|
+
x1, y1 = pts[seg_i]
|
|
508
|
+
x2, y2 = pts[seg_i + 1]
|
|
509
|
+
pts[seg_i] = (x1 + dx, y1)
|
|
510
|
+
pts[seg_i + 1] = (x2 + dx, y2)
|
|
511
|
+
return pts
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _offset_segment_y(pts: List[Pt], seg_i: int, dy: float) -> List[Pt]:
|
|
515
|
+
pts = list(pts)
|
|
516
|
+
x1, y1 = pts[seg_i]
|
|
517
|
+
x2, y2 = pts[seg_i + 1]
|
|
518
|
+
pts[seg_i] = (x1, y1 + dy)
|
|
519
|
+
pts[seg_i + 1] = (x2, y2 + dy)
|
|
520
|
+
return pts
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
_OUT = {"left": -1.0, "up": -1.0, "right": 1.0, "down": 1.0}
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _merge_shared_port_trunks(meta: List[dict], biases: List[float]) -> None:
|
|
528
|
+
"""v0.11.0: edges that share one port merge into one trunk, whatever side or
|
|
529
|
+
corridor they come from.
|
|
530
|
+
|
|
531
|
+
Lane buckets group edges by their natural trunk, so a fan-in whose sources sit on
|
|
532
|
+
opposite sides of the port (one above, one below a ``left`` port) or at different
|
|
533
|
+
distances lands in different buckets and gets two trunks a few points apart — two
|
|
534
|
+
vertical runs and a doubled stub next to the arrowhead. For every port shared by
|
|
535
|
+
two or more two-bend routes, all of them take one common trunk: the member trunk
|
|
536
|
+
closest to the port that no member has to double back to reach."""
|
|
537
|
+
def stub_end(m: dict, which: str) -> float:
|
|
538
|
+
d = m["d" + which]
|
|
539
|
+
stub = m["edge"].stub
|
|
540
|
+
k = "x" if d in ("left", "right") else "y"
|
|
541
|
+
return m[k + which] + _OUT[d] * stub
|
|
542
|
+
|
|
543
|
+
done: set = set()
|
|
544
|
+
for which, other in (("2", "1"), ("1", "2")):
|
|
545
|
+
groups: Dict[Tuple[int, int, str], List[int]] = defaultdict(list)
|
|
546
|
+
for i, m in enumerate(meta):
|
|
547
|
+
if m["kind"] not in ("h", "v") or m["edge"].route != "ortho" or \
|
|
548
|
+
m["st"].overlap != "avoid" or getattr(m["edge"], "bundle", None) or \
|
|
549
|
+
not getattr(m["edge"], "trunk", True):
|
|
550
|
+
continue
|
|
551
|
+
groups[(round(m["x" + which] * 10), round(m["y" + which] * 10), m["d" + which])].append(i)
|
|
552
|
+
for (_gx, _gy, d), idxs in groups.items():
|
|
553
|
+
idxs = [i for i in idxs if i not in done]
|
|
554
|
+
if len(idxs) < 2:
|
|
555
|
+
continue
|
|
556
|
+
trunks = {i: meta[i]["mid"] + biases[i] for i in idxs}
|
|
557
|
+
if max(trunks.values()) - min(trunks.values()) < 0.05:
|
|
558
|
+
continue
|
|
559
|
+
p, s_ = stub_end(meta[idxs[0]], which), _OUT[d]
|
|
560
|
+
|
|
561
|
+
def ok(c: float) -> bool:
|
|
562
|
+
if s_ * (c - p) < -0.01:
|
|
563
|
+
return False
|
|
564
|
+
for i in idxs:
|
|
565
|
+
m = meta[i]
|
|
566
|
+
if _OUT[m["d" + other]] * (c - stub_end(m, other)) < -0.01:
|
|
567
|
+
return False
|
|
568
|
+
return True
|
|
569
|
+
|
|
570
|
+
feas = [c for c in set(trunks.values()) if ok(c)]
|
|
571
|
+
if not feas:
|
|
572
|
+
continue
|
|
573
|
+
c = min(feas, key=lambda v: s_ * (v - p))
|
|
574
|
+
for i in idxs:
|
|
575
|
+
biases[i] = c - meta[i]["mid"]
|
|
576
|
+
done.add(i)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def apply_overlap_avoidance(
|
|
580
|
+
routes: List[Tuple["Edge", List[Pt]]],
|
|
581
|
+
*,
|
|
582
|
+
lane_gap: float = 7.0,
|
|
583
|
+
theme: Optional[Theme] = None,
|
|
584
|
+
) -> List[Tuple["Edge", List[Pt]]]:
|
|
585
|
+
"""
|
|
586
|
+
Re-route avoid-edges with per-lane mid_bias so parallel ortho corridors separate.
|
|
587
|
+
|
|
588
|
+
Algorithm:
|
|
589
|
+
1. Resolve endpoints for every edge; compute the natural ortho mid coordinate
|
|
590
|
+
(mid_x for left↔right flows, mid_y for top↔bottom).
|
|
591
|
+
2. Bucket avoid/ortho edges by rounded mid coordinate.
|
|
592
|
+
3. Within each bucket, sort by source cross-axis position; assign centered lanes
|
|
593
|
+
…,-1,0,1,… and re-route with mid_bias = lane * lane_gap.
|
|
594
|
+
4. overlap='allow' (and straight edges) keep the base route unchanged.
|
|
595
|
+
|
|
596
|
+
This avoids post-hoc segment surgery (which used to bend stubs) and still
|
|
597
|
+
separates edges that share a corridor even when their spans do not overlap.
|
|
598
|
+
"""
|
|
599
|
+
if not routes:
|
|
600
|
+
return []
|
|
601
|
+
|
|
602
|
+
# Resolve once
|
|
603
|
+
meta = []
|
|
604
|
+
for edge, base_pts in routes:
|
|
605
|
+
(x1, y1, d1), (x2, y2, d2) = edge.endpoints()
|
|
606
|
+
st = edge.resolved(theme=theme)
|
|
607
|
+
horiz = d1 in ("left", "right") and d2 in ("left", "right")
|
|
608
|
+
vert = d1 in ("up", "down") and d2 in ("up", "down")
|
|
609
|
+
ax, ay = _stub(x1, y1, d1, edge.stub)
|
|
610
|
+
bx, by = _stub(x2, y2, d2, edge.stub)
|
|
611
|
+
if horiz:
|
|
612
|
+
mid = _trunk0(ax, bx, d1, d2)
|
|
613
|
+
sort_key = (y1 + y2) / 2.0
|
|
614
|
+
kind = "h"
|
|
615
|
+
elif vert:
|
|
616
|
+
mid = _trunk0(ay, by, d1, d2)
|
|
617
|
+
sort_key = (x1 + x2) / 2.0
|
|
618
|
+
kind = "v"
|
|
619
|
+
else:
|
|
620
|
+
mid = (ax + bx) / 2.0
|
|
621
|
+
sort_key = (y1 + y2) / 2.0
|
|
622
|
+
kind = "m"
|
|
623
|
+
meta.append(
|
|
624
|
+
{
|
|
625
|
+
"edge": edge,
|
|
626
|
+
"base": base_pts,
|
|
627
|
+
"st": st,
|
|
628
|
+
"x1": x1, "y1": y1, "d1": d1,
|
|
629
|
+
"x2": x2, "y2": y2, "d2": d2,
|
|
630
|
+
"mid": mid,
|
|
631
|
+
"sort_key": sort_key,
|
|
632
|
+
"kind": kind,
|
|
633
|
+
}
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
biases = [0.0] * len(meta)
|
|
637
|
+
buckets: Dict[Tuple[str, float], List[int]] = defaultdict(list)
|
|
638
|
+
gap = max(lane_gap, 1.0)
|
|
639
|
+
for i, m in enumerate(meta):
|
|
640
|
+
if m["st"].overlap != "avoid" or m["edge"].route != "ortho":
|
|
641
|
+
continue
|
|
642
|
+
key = (m["kind"], round(m["mid"] / gap) * gap)
|
|
643
|
+
buckets[key].append(i)
|
|
644
|
+
|
|
645
|
+
def _end(i: int, which: str) -> Tuple[int, int]:
|
|
646
|
+
m = meta[i]
|
|
647
|
+
return (round(m["x" + which] * 10), round(m["y" + which] * 10))
|
|
648
|
+
|
|
649
|
+
for _key, idxs in buckets.items():
|
|
650
|
+
if len(idxs) < 2:
|
|
651
|
+
continue
|
|
652
|
+
# v0.10.0: a fan-in (edges ending on one point) or fan-out (starting from
|
|
653
|
+
# one point) shares a lane, so the edges merge into one clean trunk
|
|
654
|
+
# instead of splitting into parallel stubs next to the shared port
|
|
655
|
+
parent = {i: i for i in idxs}
|
|
656
|
+
|
|
657
|
+
def find(i: int) -> int:
|
|
658
|
+
while parent[i] != i:
|
|
659
|
+
parent[i] = parent[parent[i]]
|
|
660
|
+
i = parent[i]
|
|
661
|
+
return i
|
|
662
|
+
|
|
663
|
+
for which in ("1", "2"):
|
|
664
|
+
seen: Dict[Tuple[int, int], int] = {}
|
|
665
|
+
for i in idxs:
|
|
666
|
+
k = _end(i, which)
|
|
667
|
+
if k in seen:
|
|
668
|
+
parent[find(i)] = find(seen[k])
|
|
669
|
+
else:
|
|
670
|
+
seen[k] = i
|
|
671
|
+
groups: Dict[int, List[int]] = defaultdict(list)
|
|
672
|
+
for i in idxs:
|
|
673
|
+
groups[find(i)].append(i)
|
|
674
|
+
if len(groups) < 2:
|
|
675
|
+
continue
|
|
676
|
+
order = sorted(groups.values(),
|
|
677
|
+
key=lambda g: sum(meta[i]["sort_key"] for i in g) / len(g))
|
|
678
|
+
n = len(order)
|
|
679
|
+
for lane, g in enumerate(order):
|
|
680
|
+
for i in g:
|
|
681
|
+
biases[i] = (lane - (n - 1) / 2.0) * lane_gap
|
|
682
|
+
|
|
683
|
+
_merge_shared_port_trunks(meta, biases)
|
|
684
|
+
|
|
685
|
+
out: List[Tuple[Edge, List[Pt]]] = []
|
|
686
|
+
for i, m in enumerate(meta):
|
|
687
|
+
edge = m["edge"]
|
|
688
|
+
if abs(biases[i]) < 0.01 or edge.route != "ortho":
|
|
689
|
+
out.append((edge, list(m["base"])))
|
|
690
|
+
continue
|
|
691
|
+
pts = route_ortho(
|
|
692
|
+
m["x1"], m["y1"], m["d1"], m["x2"], m["y2"], m["d2"],
|
|
693
|
+
stub=edge.stub, mid_bias=biases[i],
|
|
694
|
+
)
|
|
695
|
+
out.append((edge, pts))
|
|
696
|
+
return out
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
_LABEL_SIDES = ("auto", "above", "below", "left", "right", "on")
|
|
701
|
+
_LABEL_ATS = ("mid", "longest", "start", "end")
|
|
702
|
+
_ROUTES = ("auto", "ortho", "straight", "curve", "loop", "over", "under", "back")
|
|
703
|
+
|
|
704
|
+
_DIRV = {"right": (1.0, 0.0), "left": (-1.0, 0.0), "up": (0.0, -1.0), "down": (0.0, 1.0)}
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def bezier_point(ctrl: Sequence[Pt], t: float) -> Pt:
|
|
708
|
+
(x0, y0), (x1, y1), (x2, y2), (x3, y3) = ctrl
|
|
709
|
+
u = 1.0 - t
|
|
710
|
+
a, b, c, d = u * u * u, 3 * u * u * t, 3 * u * t * t, t * t * t
|
|
711
|
+
return (a * x0 + b * x1 + c * x2 + d * x3, a * y0 + b * y1 + c * y2 + d * y3)
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def bezier_sample(ctrl: Sequence[Pt], n: int = 32) -> List[Pt]:
|
|
715
|
+
return [bezier_point(ctrl, i / n) for i in range(n + 1)]
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def curve_controls(x1: float, y1: float, d1: str, x2: float, y2: float, d2: str,
|
|
719
|
+
bend: Optional[float] = None, tension: float = 0.45) -> Tuple[Pt, Pt, Pt, Pt]:
|
|
720
|
+
"""Cubic control polygon for ``route='curve'``.
|
|
721
|
+
|
|
722
|
+
``bend=None`` leaves each end along its port's outward direction (a smooth
|
|
723
|
+
S/C curve, like draw.io's curved connector); ``bend=θ`` (degrees, TikZ
|
|
724
|
+
``bend left=θ``; negative bends right) uses a symmetric arc leaving at θ to
|
|
725
|
+
the chord. Control arms scale with the end-to-end distance.
|
|
726
|
+
"""
|
|
727
|
+
import math
|
|
728
|
+
|
|
729
|
+
L = math.hypot(x2 - x1, y2 - y1) or 1.0
|
|
730
|
+
k = max(14.0, tension * L)
|
|
731
|
+
if bend is not None:
|
|
732
|
+
a = math.atan2(y2 - y1, x2 - x1)
|
|
733
|
+
th = math.radians(bend)
|
|
734
|
+
k = L * 0.39 / max(0.35, math.cos(th))
|
|
735
|
+
c1 = (x1 + k * math.cos(a - th), y1 + k * math.sin(a - th))
|
|
736
|
+
c2 = (x2 - k * math.cos(a + th), y2 - k * math.sin(a + th))
|
|
737
|
+
return (x1, y1), c1, c2, (x2, y2)
|
|
738
|
+
v1 = _DIRV.get(d1)
|
|
739
|
+
v2 = _DIRV.get(d2)
|
|
740
|
+
ux, uy = (x2 - x1) / L, (y2 - y1) / L
|
|
741
|
+
if v1 is None:
|
|
742
|
+
v1 = (ux, uy)
|
|
743
|
+
if v2 is None:
|
|
744
|
+
v2 = (-ux, -uy)
|
|
745
|
+
c1 = (x1 + k * v1[0], y1 + k * v1[1])
|
|
746
|
+
c2 = (x2 + k * v2[0], y2 + k * v2[1])
|
|
747
|
+
return (x1, y1), c1, c2, (x2, y2)
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _trim_bezier(ctrl: Sequence[Pt], start: float, end: float) -> Tuple[Pt, Pt, Pt, Pt]:
|
|
751
|
+
"""Pull the curve ends back by ``start`` / ``end`` px along their tangents."""
|
|
752
|
+
import math
|
|
753
|
+
|
|
754
|
+
p0, c1, c2, p3 = [tuple(p) for p in ctrl]
|
|
755
|
+
|
|
756
|
+
def unit(a: Pt, b: Pt) -> Pt:
|
|
757
|
+
dx, dy = b[0] - a[0], b[1] - a[1]
|
|
758
|
+
n = math.hypot(dx, dy) or 1.0
|
|
759
|
+
return dx / n, dy / n
|
|
760
|
+
|
|
761
|
+
if end > 0:
|
|
762
|
+
ux, uy = unit(c2 if c2 != p3 else c1, p3)
|
|
763
|
+
p3 = (p3[0] - ux * end, p3[1] - uy * end)
|
|
764
|
+
c2 = (c2[0] - ux * end, c2[1] - uy * end)
|
|
765
|
+
if start > 0:
|
|
766
|
+
ux, uy = unit(c1 if c1 != p0 else c2, p0)
|
|
767
|
+
p0 = (p0[0] - ux * start, p0[1] - uy * start)
|
|
768
|
+
c1 = (c1[0] - ux * start, c1[1] - uy * start)
|
|
769
|
+
return p0, c1, c2, p3 # type: ignore[return-value]
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def _trim_polyline(pts: List[Pt], start: float, end: float) -> List[Pt]:
|
|
773
|
+
import math
|
|
774
|
+
|
|
775
|
+
out = list(pts)
|
|
776
|
+
if end > 0 and len(out) >= 2:
|
|
777
|
+
(ax, ay), (bx, by) = out[-2], out[-1]
|
|
778
|
+
n = math.hypot(bx - ax, by - ay) or 1.0
|
|
779
|
+
e = min(end, n * 0.95)
|
|
780
|
+
out[-1] = (bx - (bx - ax) / n * e, by - (by - ay) / n * e)
|
|
781
|
+
if start > 0 and len(out) >= 2:
|
|
782
|
+
(ax, ay), (bx, by) = out[0], out[1]
|
|
783
|
+
n = math.hypot(bx - ax, by - ay) or 1.0
|
|
784
|
+
s = min(start, n * 0.95)
|
|
785
|
+
out[0] = (ax + (bx - ax) / n * s, ay + (by - ay) / n * s)
|
|
786
|
+
return out
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _merge_collinear(pts: Sequence[Pt], eps: float = 0.5) -> List[Pt]:
|
|
790
|
+
"""Drop duplicate and straight-through vertices (a run of collinear segments
|
|
791
|
+
is one segment for label placement)."""
|
|
792
|
+
out: List[Pt] = []
|
|
793
|
+
for p in pts:
|
|
794
|
+
if out and abs(p[0] - out[-1][0]) < eps and abs(p[1] - out[-1][1]) < eps:
|
|
795
|
+
continue
|
|
796
|
+
if len(out) >= 2:
|
|
797
|
+
a, b = out[-2], out[-1]
|
|
798
|
+
cross = (b[0] - a[0]) * (p[1] - b[1]) - (b[1] - a[1]) * (p[0] - b[0])
|
|
799
|
+
dot = (b[0] - a[0]) * (p[0] - b[0]) + (b[1] - a[1]) * (p[1] - b[1])
|
|
800
|
+
if abs(cross) < eps * max(1.0, abs(dot) ** 0.5) and dot > 0:
|
|
801
|
+
out[-1] = p
|
|
802
|
+
continue
|
|
803
|
+
out.append(p)
|
|
804
|
+
return out if len(out) >= 2 else list(pts)
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def knockout_clip(buf: List[str], holes: Sequence[Tuple[float, float, float, float]]) -> str:
|
|
808
|
+
"""Emit a clipPath that keeps everything *except* ``holes``; return its id.
|
|
809
|
+
|
|
810
|
+
The holes are drawn with reversed winding (nonzero rule), which every SVG
|
|
811
|
+
renderer — cairosvg included — honours, unlike ``clip-rule=evenodd``.
|
|
812
|
+
"""
|
|
813
|
+
global _CLIP_SEQ
|
|
814
|
+
_CLIP_SEQ += 1
|
|
815
|
+
cid = f"cf-ko-{_CLIP_SEQ}"
|
|
816
|
+
big = 1.0e5
|
|
817
|
+
d = [f"M {-big},{-big} H {big} V {big} H {-big} Z"]
|
|
818
|
+
for x, y, w, h in holes:
|
|
819
|
+
d.append(f"M {x:.2f},{y:.2f} V {y + h:.2f} H {x + w:.2f} V {y:.2f} Z")
|
|
820
|
+
buf.append(f'<defs><clipPath id="{cid}"><path d="{" ".join(d)}"/></clipPath></defs>')
|
|
821
|
+
return cid
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
_CLIP_SEQ = 0
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def _poly_hits_rect(pts: Sequence[Pt], r: Tuple[float, float, float, float]) -> bool:
|
|
828
|
+
from .routing import seg_rect_intersect
|
|
829
|
+
|
|
830
|
+
return any(seg_rect_intersect(pts[i], pts[i + 1], r) for i in range(len(pts) - 1))
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
class Edge:
|
|
834
|
+
"""
|
|
835
|
+
Connector between two Ports (or Nodes).
|
|
836
|
+
|
|
837
|
+
route: ortho | straight | curve | loop (feedback around content).
|
|
838
|
+
Styling knobs (aliases accepted): color/stroke, opacity/alpha, width/stroke_width,
|
|
839
|
+
style (solid|dashed|dotted|dashdot|custom dash such as ``(6, 2)``), overlap (allow|avoid).
|
|
840
|
+
|
|
841
|
+
Arrowheads (each end independently): ``head=`` (end) / ``tail=`` (start) take a
|
|
842
|
+
shape name — triangle | stealth | vee | open | circle | diamond | bar | square | none —
|
|
843
|
+
or a :class:`~codefig.styles.Head` (shape, size, length, width, hollow, fill).
|
|
844
|
+
``head_size`` scales both, ``head_length``/``head_width`` set them exactly,
|
|
845
|
+
``hollow=True`` draws outlined heads. Theme defaults: ``edge_head``, ``edge_tail``…
|
|
846
|
+
|
|
847
|
+
``route='curve'``: smooth cubic Bézier; ``bend=`` degrees (TikZ ``bend left``),
|
|
848
|
+
otherwise the ends leave along the port directions. Heads follow the tangent.
|
|
849
|
+
|
|
850
|
+
``knockout`` (default from the theme / figure policy): soft text that the edge
|
|
851
|
+
passes under (free Text, edge labels) cuts a small gap in the line so the text
|
|
852
|
+
stays readable instead of forcing a detour.
|
|
853
|
+
loop_side / clearance control route="loop". ``bundle="id"`` groups parallel edges:
|
|
854
|
+
``bundle_style="lanes"`` (theme default since v0.7.0) draws them as evenly spaced
|
|
855
|
+
parallel trunks in a crossing-minimising order; ``"merge"`` shares one trunk;
|
|
856
|
+
``"bus"`` adds the old translucent underlay. ``bus=`` is an alias of ``bundle=``.
|
|
857
|
+
|
|
858
|
+
``step=`` (v0.5.0; int, str or :class:`~codefig.steps.Step`) draws a numbered
|
|
859
|
+
step badge (①) on the path; ``step_at`` = fraction 0..1 of the path length or
|
|
860
|
+
``"start" | "mid" | "end"`` (default ``"mid"``). The badge slides along the path
|
|
861
|
+
away from nodes / labels and is checked like an edge label by check_overlaps().
|
|
862
|
+
Example: ``Edge(a, b, step=2)`` or ``Edge(a, b, step=steps.next(), step_at="start")``.
|
|
863
|
+
|
|
864
|
+
``via=node`` (v0.12.0) passes the arrow *through* any node — an ``OpCircle`` /
|
|
865
|
+
``Op``, a router ``Box``, a bus — as two drawn legs (``a → node`` without a head,
|
|
866
|
+
``node → b`` with the head and label); both legs meet ``node`` on the sides facing
|
|
867
|
+
their other ends and, since v0.13.0, stay level with a facing side port, so several
|
|
868
|
+
``Edge(p_i, q_i, via=loader)`` through one tall box are straight lines.
|
|
869
|
+
``trunk=False`` (v0.12.0) opts this edge out of shared-port trunk merging (edges
|
|
870
|
+
that share a port merge into one trunk by default — fan-in / fan-out), so two
|
|
871
|
+
edges into one box arrive as two separate arrows.
|
|
872
|
+
``parallel=N`` (v0.13.0) draws N straight arrows side by side between the two nodes
|
|
873
|
+
(multi-head attention inputs, k/v pairs); ``parallel_gap=`` sets their pitch in pt
|
|
874
|
+
(default ≈ the shared overlap / (N + 1), 4–10 pt); explicit side ports keep their
|
|
875
|
+
side. ``port(side, offset=pt)`` shifts one port along its side.
|
|
876
|
+
A legend entry for a line style: ``Legend([Key("PCIe", kind="arrow", style="dashed")])``
|
|
877
|
+
or ``Key.from_edge(edge, "label")``.
|
|
878
|
+
Public API takes no absolute coordinates.
|
|
879
|
+
"""
|
|
880
|
+
|
|
881
|
+
stub = themed(12.0)
|
|
882
|
+
clearance = themed(20.0) # v0.7.0: was 40 (loops hugged the panel edge)
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def __init__(
|
|
886
|
+
self,
|
|
887
|
+
src: Union[Port, Node],
|
|
888
|
+
dst: Union[Port, Node],
|
|
889
|
+
*,
|
|
890
|
+
route: str = "ortho",
|
|
891
|
+
label: Optional[str] = None,
|
|
892
|
+
marker_end: bool = True,
|
|
893
|
+
marker_start: bool = False,
|
|
894
|
+
# style
|
|
895
|
+
color: Optional[str] = None,
|
|
896
|
+
stroke: Optional[str] = None,
|
|
897
|
+
opacity: Optional[float] = None,
|
|
898
|
+
alpha: Optional[float] = None,
|
|
899
|
+
width: Optional[float] = None,
|
|
900
|
+
stroke_width: Optional[float] = None,
|
|
901
|
+
head_size: Optional[float] = None,
|
|
902
|
+
style: Any = None,
|
|
903
|
+
overlap: Optional[str] = None,
|
|
904
|
+
head: Any = None,
|
|
905
|
+
tail: Any = None,
|
|
906
|
+
head_length: Optional[float] = None,
|
|
907
|
+
head_width: Optional[float] = None,
|
|
908
|
+
hollow: Optional[bool] = None,
|
|
909
|
+
head_fill: Optional[str] = None,
|
|
910
|
+
bend: Optional[float] = None,
|
|
911
|
+
knockout: Optional[bool] = None,
|
|
912
|
+
legend: Optional[str] = None,
|
|
913
|
+
stub: Optional[float] = None,
|
|
914
|
+
loop_side: str = "auto", # right|left|top|bottom|auto — for route="loop"
|
|
915
|
+
loop_gap: Optional[float] = None,
|
|
916
|
+
jog_tol: Optional[float] = None,
|
|
917
|
+
clearance: Optional[float] = None, # how far the loop trunk sits outside ports
|
|
918
|
+
bundle: Optional[str] = None, # shared bus id — parallel edges share a trunk
|
|
919
|
+
bus: Optional[str] = None, # alias of bundle
|
|
920
|
+
bundle_style: Optional[str] = None, # v0.7.0: lanes | merge | bus (theme default)
|
|
921
|
+
theme: Optional[Theme] = None,
|
|
922
|
+
# label placement (all optional; defaults keep the legacy "above the
|
|
923
|
+
# middle point" behaviour)
|
|
924
|
+
label_at: Optional[Union[float, str]] = None, # 0..1 | mid | longest | start | end
|
|
925
|
+
label_side: Optional[str] = None, # auto | above | below | left | right | on
|
|
926
|
+
label_rotate: Union[bool, str] = False, # True/"along": follow vertical segments
|
|
927
|
+
label_bg: Optional[str] = None, # halo colour behind the label ("on" → white)
|
|
928
|
+
label_size: Optional[float] = None,
|
|
929
|
+
label_color: Optional[str] = None,
|
|
930
|
+
label_gap: Optional[float] = None,
|
|
931
|
+
around: Optional[Node] = None, # route="loop": trunk clears this node's box
|
|
932
|
+
snap: bool = False, # straight connector when facing side ports overlap
|
|
933
|
+
step: Any = None, # v0.5.0: numbered badge (int | str | Step) on the path
|
|
934
|
+
step_at: Union[float, str] = "mid", # 0..1 | start | mid | end
|
|
935
|
+
align: bool = True, # v0.11.0: may the layout nudge nodes to straighten me?
|
|
936
|
+
via: Any = None, # v0.12.0: node to pass through; v0.14.0: (node, side) waypoints
|
|
937
|
+
trunk: bool = True, # v0.12.0: False → never merge my trunk with shared-port edges
|
|
938
|
+
parallel: int = 1, # v0.13.0: draw N side-by-side arrows (multi-head, fan of copies)
|
|
939
|
+
parallel_gap: Optional[float] = None, # v0.13.0: their spacing (pt; default ≈ 6)
|
|
940
|
+
auto_wrap: bool = True, # v0.14.0: may the wrap-before-shrink pass break my label?
|
|
941
|
+
) -> None:
|
|
942
|
+
_init_kw = {k: v for k, v in locals().items()
|
|
943
|
+
if k not in ("self", "src", "dst", "via", "__class__", "parallel",
|
|
944
|
+
"parallel_gap")}
|
|
945
|
+
if int(parallel) < 1:
|
|
946
|
+
raise ValueError("Edge(parallel=) must be >= 1")
|
|
947
|
+
if int(parallel) > 1 and via is not None:
|
|
948
|
+
raise ValueError("Edge(parallel=) cannot be combined with via=")
|
|
949
|
+
self.parallel = int(parallel)
|
|
950
|
+
self.parallel_gap = None if parallel_gap is None else float(parallel_gap)
|
|
951
|
+
from ._validate import check_enum
|
|
952
|
+
loop_side = check_enum("edges(loop_side)", loop_side, ("auto", "top", "bottom", "left", "right"))
|
|
953
|
+
for name, value in (("loop_gap", loop_gap), ("jog_tol", jog_tol)):
|
|
954
|
+
if value is not None and value < 0:
|
|
955
|
+
raise ValueError(f"{name} must be nonnegative, got {value}")
|
|
956
|
+
self.loop_gap = loop_gap
|
|
957
|
+
self.jog_tol = jog_tol
|
|
958
|
+
from ._validate import check_enum
|
|
959
|
+
route = check_enum("edges(route)", route, _ROUTES)
|
|
960
|
+
# bare nodes (no explicit Port): sides follow the layout — right→left when
|
|
961
|
+
# dst is to the right (the classic default), bottom→top when stacked, …
|
|
962
|
+
self._bare_ends = not isinstance(src, Port) and not isinstance(dst, Port)
|
|
963
|
+
# v0.14.0: via=(node, side) / [(node, side), ...] are waypoints *beside* nodes
|
|
964
|
+
self.waypoints: List[Tuple[Node, str]] = []
|
|
965
|
+
if via is not None and not isinstance(via, Node):
|
|
966
|
+
wps = [via] if (isinstance(via, tuple) and len(via) == 2
|
|
967
|
+
and isinstance(via[1], str)) else list(via)
|
|
968
|
+
for w in wps:
|
|
969
|
+
if not (isinstance(w, tuple) and len(w) == 2 and isinstance(w[0], Node)
|
|
970
|
+
and w[1] in ("top", "bottom", "left", "right")):
|
|
971
|
+
raise TypeError("Edge(via=) takes a node to pass through, or (node, side) "
|
|
972
|
+
"waypoints with side top|bottom|left|right, e.g. "
|
|
973
|
+
"via=(enc, 'top') or via=[(a, 'top'), (b, 'bottom')]")
|
|
974
|
+
self.waypoints.append((w[0], w[1]))
|
|
975
|
+
via = None
|
|
976
|
+
if route not in ("ortho", "over", "under"):
|
|
977
|
+
raise ValueError("Edge(via=(node, side)) waypoints need route='ortho'")
|
|
978
|
+
route = "ortho"
|
|
979
|
+
if route in ("over", "under") and self._bare_ends:
|
|
980
|
+
side = "top" if route == "over" else "bottom"
|
|
981
|
+
src, dst = src.port(side), dst.port(side)
|
|
982
|
+
self._bare_ends = False
|
|
983
|
+
self.src = _as_port(src, "right")
|
|
984
|
+
self.dst = _as_port(dst, "left")
|
|
985
|
+
self.route = route
|
|
986
|
+
self.align = bool(align)
|
|
987
|
+
self.auto_wrap = bool(auto_wrap)
|
|
988
|
+
self.label = label
|
|
989
|
+
self.marker_end = marker_end
|
|
990
|
+
self.marker_start = marker_start
|
|
991
|
+
self.color = color
|
|
992
|
+
self.stroke = stroke
|
|
993
|
+
self.opacity = opacity
|
|
994
|
+
self.alpha = alpha
|
|
995
|
+
self.width = width
|
|
996
|
+
self.stroke_width = stroke_width
|
|
997
|
+
self.head_size = head_size
|
|
998
|
+
self.style = style
|
|
999
|
+
self.overlap = overlap
|
|
1000
|
+
self.head = head
|
|
1001
|
+
self.tail = tail
|
|
1002
|
+
self.head_length = head_length
|
|
1003
|
+
self.head_width = head_width
|
|
1004
|
+
self.hollow = hollow
|
|
1005
|
+
self.head_fill = head_fill
|
|
1006
|
+
self.bend = bend
|
|
1007
|
+
self.knockout = knockout
|
|
1008
|
+
self.legend = legend
|
|
1009
|
+
self.stub = stub
|
|
1010
|
+
self.loop_side = loop_side
|
|
1011
|
+
self.clearance = clearance
|
|
1012
|
+
self.bundle = bundle if bundle is not None else bus
|
|
1013
|
+
if bundle_style is not None and bundle_style not in ("lanes", "merge", "bus"):
|
|
1014
|
+
from ._validate import check_enum
|
|
1015
|
+
check_enum("Edge(bundle_style)", bundle_style, ("lanes", "merge", "bus"))
|
|
1016
|
+
self.bundle_style = bundle_style
|
|
1017
|
+
self._theme = theme
|
|
1018
|
+
if label_side is not None and label_side not in _LABEL_SIDES:
|
|
1019
|
+
from ._validate import check_enum
|
|
1020
|
+
check_enum("Edge(label_side)", label_side, _LABEL_SIDES)
|
|
1021
|
+
if isinstance(label_at, str) and label_at not in _LABEL_ATS:
|
|
1022
|
+
from ._validate import check_enum
|
|
1023
|
+
check_enum("Edge(label_at)", label_at, _LABEL_ATS)
|
|
1024
|
+
if isinstance(label_at, (int, float)) and not 0.0 <= float(label_at) <= 1.0:
|
|
1025
|
+
raise ValueError(f"label_at fraction must be within 0..1, got {label_at!r}")
|
|
1026
|
+
self.label_at = label_at
|
|
1027
|
+
self.label_side = label_side
|
|
1028
|
+
self.label_rotate = label_rotate
|
|
1029
|
+
self.label_bg = label_bg
|
|
1030
|
+
self.label_size = label_size
|
|
1031
|
+
self.label_color = label_color
|
|
1032
|
+
self.label_gap = label_gap
|
|
1033
|
+
self.around = around
|
|
1034
|
+
self.snap = bool(snap)
|
|
1035
|
+
from .steps import as_step
|
|
1036
|
+
|
|
1037
|
+
self.step = as_step(step, theme=self._theme)
|
|
1038
|
+
if isinstance(step_at, str) and step_at not in ("start", "mid", "end"):
|
|
1039
|
+
from ._validate import check_enum
|
|
1040
|
+
check_enum("Edge(step_at)", step_at, ("start", "mid", "end"))
|
|
1041
|
+
if isinstance(step_at, (int, float)) and not 0.0 <= float(step_at) <= 1.0:
|
|
1042
|
+
raise ValueError(f"step_at fraction must be within 0..1, got {step_at!r}")
|
|
1043
|
+
self.step_at = step_at
|
|
1044
|
+
self.trunk = bool(trunk)
|
|
1045
|
+
if self.waypoints or route in ("over", "under"):
|
|
1046
|
+
# the author fixed the path: no trunk merging, no nudging nodes to straighten it
|
|
1047
|
+
self.trunk, self.align = False, False
|
|
1048
|
+
self.via = via
|
|
1049
|
+
self._init_kw = _init_kw
|
|
1050
|
+
self._src_raw, self._dst_raw = src, dst
|
|
1051
|
+
self._legs: Optional[List["Edge"]] = None
|
|
1052
|
+
self._cached_pts: Optional[List[Pt]] = None
|
|
1053
|
+
self._bezier: Optional[Tuple[Pt, Pt, Pt, Pt]] = None
|
|
1054
|
+
self.resolved() # validate style / heads early
|
|
1055
|
+
|
|
1056
|
+
def legs(self) -> List["Edge"]:
|
|
1057
|
+
"""v0.12.0: the drawn parts of this edge.
|
|
1058
|
+
|
|
1059
|
+
A plain edge is its own single leg. ``Edge(a, b, via=n)`` becomes two legs —
|
|
1060
|
+
``a → n`` (no arrowhead) and ``n → b`` (the head, label and step) — so one
|
|
1061
|
+
logical arrow can pass *through* a node (a ⊕ / ⊙ operator, a router, a bus
|
|
1062
|
+
tap) without the author splitting it by hand. Both legs enter / leave ``n``
|
|
1063
|
+
on the sides facing their other end (``port("auto")``). Diagrams expand a
|
|
1064
|
+
``via`` edge into its legs when it is added.
|
|
1065
|
+
"""
|
|
1066
|
+
if self.via is None and self.parallel > 1:
|
|
1067
|
+
if self._legs is None:
|
|
1068
|
+
self._legs = self._parallel_legs()
|
|
1069
|
+
return self._legs
|
|
1070
|
+
if self.via is None:
|
|
1071
|
+
return [self]
|
|
1072
|
+
if self._legs is None:
|
|
1073
|
+
kw = dict(self._init_kw)
|
|
1074
|
+
k1 = dict(kw, label=None, step=None, head="none", marker_end=False, legend=None)
|
|
1075
|
+
k2 = dict(kw, tail=None, marker_start=False)
|
|
1076
|
+
a = Edge(self._src_raw, self.via.port("auto"), **k1)
|
|
1077
|
+
b = Edge(self.via.port("auto"), self._dst_raw, **k2)
|
|
1078
|
+
for leg in (a, b):
|
|
1079
|
+
leg.lint_ignore = getattr(self, "lint_ignore", None)
|
|
1080
|
+
self._legs = [a, b]
|
|
1081
|
+
return self._legs
|
|
1082
|
+
|
|
1083
|
+
def _parallel_legs(self) -> List["Edge"]:
|
|
1084
|
+
"""v0.13.0: ``Edge(a, b, parallel=3)`` → three straight arrows side by side.
|
|
1085
|
+
|
|
1086
|
+
Explicit side ports keep their side (``Edge(a.port("bottom"), b.port("top"),
|
|
1087
|
+
parallel=3)``); bare nodes use the sides facing each other. The label (and step
|
|
1088
|
+
badge) go on the middle arrow; the legs never share a trunk or nudge nodes.
|
|
1089
|
+
"""
|
|
1090
|
+
n = self.parallel
|
|
1091
|
+
sn, dn = self.src.node, self.dst.node
|
|
1092
|
+
ss = self.src.side if (self.src.kind == "side" and not self._bare_ends) else "auto"
|
|
1093
|
+
ds = self.dst.side if (self.dst.kind == "side" and not self._bare_ends) else "auto"
|
|
1094
|
+
mid = n // 2
|
|
1095
|
+
out = []
|
|
1096
|
+
for i in range(n):
|
|
1097
|
+
kw = dict(self._init_kw, trunk=False, align=False, snap=False)
|
|
1098
|
+
if i != mid:
|
|
1099
|
+
kw.update(label=None, step=None, legend=None)
|
|
1100
|
+
sp = Port(sn, kind="parallel", side=ss, peer=dn, par_i=i, par_n=n, par_gap=self.parallel_gap)
|
|
1101
|
+
dp = Port(dn, kind="parallel", side=ds, peer=sn, par_i=i, par_n=n, par_gap=self.parallel_gap)
|
|
1102
|
+
leg = Edge(sp, dp, **kw)
|
|
1103
|
+
leg.lint_ignore = getattr(self, "lint_ignore", None)
|
|
1104
|
+
out.append(leg)
|
|
1105
|
+
for leg in out:
|
|
1106
|
+
leg._par_group = out
|
|
1107
|
+
leg._par_label = (self.label, self.label_side, self.step)
|
|
1108
|
+
return out
|
|
1109
|
+
|
|
1110
|
+
def _par_assign_label(self) -> None:
|
|
1111
|
+
"""Put a parallel group's label on its outermost arrow (right of vertical
|
|
1112
|
+
arrows, above horizontal ones) once the direction is known."""
|
|
1113
|
+
group = getattr(self, "_par_group", None)
|
|
1114
|
+
if not group or group[0] is not self:
|
|
1115
|
+
return
|
|
1116
|
+
label, side, step = self._par_label
|
|
1117
|
+
if label is None and step is None:
|
|
1118
|
+
return
|
|
1119
|
+
try:
|
|
1120
|
+
(x1, y1, d1), _b = self.endpoints()
|
|
1121
|
+
except Exception: # pragma: no cover
|
|
1122
|
+
return
|
|
1123
|
+
vertical = d1 in ("up", "down")
|
|
1124
|
+
owner = max(group, key=lambda e: e.endpoints()[0][0]) if vertical else \
|
|
1125
|
+
min(group, key=lambda e: e.endpoints()[0][1])
|
|
1126
|
+
for e in group:
|
|
1127
|
+
e.label = label if e is owner else None
|
|
1128
|
+
e.step = step if e is owner else None
|
|
1129
|
+
if e is owner and side is None:
|
|
1130
|
+
e.label_side = "right" if vertical else "above"
|
|
1131
|
+
|
|
1132
|
+
@property
|
|
1133
|
+
def theme(self) -> Theme:
|
|
1134
|
+
"""Explicit ``theme=`` or the active theme (Figure / ``use_theme``)."""
|
|
1135
|
+
return self._theme if self._theme is not None else current_theme()
|
|
1136
|
+
|
|
1137
|
+
@theme.setter
|
|
1138
|
+
def theme(self, value: Optional[Theme]) -> None:
|
|
1139
|
+
self._theme = value
|
|
1140
|
+
|
|
1141
|
+
def _theme_for_resolve(self) -> Theme:
|
|
1142
|
+
return self.theme
|
|
1143
|
+
|
|
1144
|
+
def resolved(self, theme: Optional[Theme] = None) -> ResolvedEdgeStyle:
|
|
1145
|
+
th = theme or self.theme
|
|
1146
|
+
tail = self.tail
|
|
1147
|
+
if tail is None and self.marker_start:
|
|
1148
|
+
tail = self.head if self.head is not None else th.edge_head
|
|
1149
|
+
head = self.head
|
|
1150
|
+
if not self.marker_end:
|
|
1151
|
+
head = "none"
|
|
1152
|
+
return resolve_edge_style(
|
|
1153
|
+
th,
|
|
1154
|
+
color=self.color,
|
|
1155
|
+
stroke=self.stroke,
|
|
1156
|
+
opacity=self.opacity,
|
|
1157
|
+
alpha=self.alpha,
|
|
1158
|
+
width=self.width,
|
|
1159
|
+
stroke_width=self.stroke_width,
|
|
1160
|
+
head_size=self.head_size,
|
|
1161
|
+
style=self.style,
|
|
1162
|
+
overlap=self.overlap,
|
|
1163
|
+
head=head,
|
|
1164
|
+
tail=tail,
|
|
1165
|
+
head_length=self.head_length,
|
|
1166
|
+
head_width=self.head_width,
|
|
1167
|
+
hollow=self.hollow,
|
|
1168
|
+
head_fill=self.head_fill,
|
|
1169
|
+
)
|
|
1170
|
+
|
|
1171
|
+
def curve_ctrl(self, obstacles: Optional[Sequence[Any]] = None) -> Tuple[Pt, Pt, Pt, Pt]:
|
|
1172
|
+
"""Control polygon for route='curve' (tries alternative bends around hard obstacles)."""
|
|
1173
|
+
(x1, y1, d1), (x2, y2, d2) = self.endpoints()
|
|
1174
|
+
ctrl = curve_controls(x1, y1, d1, x2, y2, d2, bend=self.bend)
|
|
1175
|
+
if self.bend is None and obstacles:
|
|
1176
|
+
from .routing import path_cost
|
|
1177
|
+
|
|
1178
|
+
def cost(c):
|
|
1179
|
+
return path_cost(bezier_sample(c, 24), obstacles)
|
|
1180
|
+
|
|
1181
|
+
best, bc = ctrl, cost(ctrl)
|
|
1182
|
+
if bc >= 10000:
|
|
1183
|
+
for cand in ([curve_controls(x1, y1, d1, x2, y2, d2, tension=t) for t in (0.25, 0.7)]
|
|
1184
|
+
+ [curve_controls(x1, y1, d1, x2, y2, d2, bend=b)
|
|
1185
|
+
for b in (25, -25, 40, -40, 55, -55)]):
|
|
1186
|
+
c = cost(cand) + 1.0
|
|
1187
|
+
if c < bc:
|
|
1188
|
+
best, bc = cand, c
|
|
1189
|
+
ctrl = best
|
|
1190
|
+
return ctrl
|
|
1191
|
+
|
|
1192
|
+
def points(self) -> List[Pt]:
|
|
1193
|
+
if self._cached_pts is not None:
|
|
1194
|
+
return self._cached_pts
|
|
1195
|
+
(x1, y1, d1), (x2, y2, d2) = self.endpoints()
|
|
1196
|
+
if self.route == "straight":
|
|
1197
|
+
# v0.12.0: a straight route is one segment unless ``stub=`` is given
|
|
1198
|
+
explicit = self.__dict__.get("_th_stub")
|
|
1199
|
+
return route_straight(x1, y1, d1, x2, y2, d2, stub=explicit or 0.0)
|
|
1200
|
+
if self._tiny_jog():
|
|
1201
|
+
return [(x1, y1), (x2, y2)]
|
|
1202
|
+
if self.route == "curve":
|
|
1203
|
+
self._bezier = self.curve_ctrl()
|
|
1204
|
+
return bezier_sample(self._bezier)
|
|
1205
|
+
if self.route in ("loop", "back"):
|
|
1206
|
+
return route_loop(
|
|
1207
|
+
x1, y1, d1, x2, y2, d2,
|
|
1208
|
+
stub=self.stub, side=self.loop_side, clearance=self.clearance,
|
|
1209
|
+
)
|
|
1210
|
+
return route_ortho(x1, y1, d1, x2, y2, d2, stub=self.stub)
|
|
1211
|
+
|
|
1212
|
+
def knockout_enabled(self, theme: Optional[Theme] = None) -> bool:
|
|
1213
|
+
from .policy import knockout_enabled
|
|
1214
|
+
|
|
1215
|
+
if self.knockout is not None:
|
|
1216
|
+
return bool(self.knockout)
|
|
1217
|
+
return knockout_enabled(theme or self.theme)
|
|
1218
|
+
|
|
1219
|
+
def draw(
|
|
1220
|
+
self,
|
|
1221
|
+
buf: List[str],
|
|
1222
|
+
ox: float = 0.0,
|
|
1223
|
+
oy: float = 0.0,
|
|
1224
|
+
*,
|
|
1225
|
+
theme: Optional[Theme] = None,
|
|
1226
|
+
points: Optional[List[Pt]] = None,
|
|
1227
|
+
obstacles: Optional[List[Tuple[float, float, float, float]]] = None,
|
|
1228
|
+
defer_label: bool = False,
|
|
1229
|
+
curve: Optional[Sequence[Pt]] = None,
|
|
1230
|
+
holes: Optional[Sequence[Tuple[float, float, float, float]]] = None,
|
|
1231
|
+
) -> None:
|
|
1232
|
+
from .render_svg import angle_of, draw_head, draw_path
|
|
1233
|
+
|
|
1234
|
+
th = theme or self.theme
|
|
1235
|
+
st = self.resolved(th)
|
|
1236
|
+
if points is None:
|
|
1237
|
+
pts = self.points()
|
|
1238
|
+
if self.route == "curve" and curve is None:
|
|
1239
|
+
curve = self._bezier
|
|
1240
|
+
else:
|
|
1241
|
+
pts = points
|
|
1242
|
+
if len(pts) < 2:
|
|
1243
|
+
return
|
|
1244
|
+
pts = [(x + ox, y + oy) for x, y in pts]
|
|
1245
|
+
eh, sh = st.end_head, st.start_head
|
|
1246
|
+
sb_end = eh.setback(st.width) if eh is not None and eh.visible else 0.0
|
|
1247
|
+
sb_start = sh.setback(st.width) if sh is not None and sh.visible else 0.0
|
|
1248
|
+
|
|
1249
|
+
if holes:
|
|
1250
|
+
cid = knockout_clip(buf, holes)
|
|
1251
|
+
buf.append(f'<g clip-path="url(#{cid})">')
|
|
1252
|
+
if curve is not None:
|
|
1253
|
+
ctrl = [(x + ox, y + oy) for x, y in curve]
|
|
1254
|
+
p0, c1, c2, p3 = _trim_bezier(ctrl, sb_start, sb_end)
|
|
1255
|
+
d = (f"M {p0[0]:.2f},{p0[1]:.2f} C {c1[0]:.2f},{c1[1]:.2f} "
|
|
1256
|
+
f"{c2[0]:.2f},{c2[1]:.2f} {p3[0]:.2f},{p3[1]:.2f}")
|
|
1257
|
+
end_from = ctrl[2] if ctrl[2] != ctrl[3] else ctrl[1]
|
|
1258
|
+
start_from = ctrl[1] if ctrl[1] != ctrl[0] else ctrl[2]
|
|
1259
|
+
end_ang = angle_of(end_from[0], end_from[1], ctrl[3][0], ctrl[3][1])
|
|
1260
|
+
start_ang = angle_of(start_from[0], start_from[1], ctrl[0][0], ctrl[0][1])
|
|
1261
|
+
tip_end, tip_start = ctrl[3], ctrl[0]
|
|
1262
|
+
else:
|
|
1263
|
+
dp = _trim_polyline(pts, sb_start, sb_end)
|
|
1264
|
+
d = f"M {dp[0][0]:.2f},{dp[0][1]:.2f} " + " ".join(f"L {x:.2f},{y:.2f}" for x, y in dp[1:])
|
|
1265
|
+
end_ang = angle_of(pts[-2][0], pts[-2][1], pts[-1][0], pts[-1][1])
|
|
1266
|
+
start_ang = angle_of(pts[1][0], pts[1][1], pts[0][0], pts[0][1])
|
|
1267
|
+
tip_end, tip_start = pts[-1], pts[0]
|
|
1268
|
+
draw_path(buf, d, stroke=st.color, stroke_width=st.width, opacity=st.opacity,
|
|
1269
|
+
dasharray=st.dasharray(), marker_end=False, marker_start=False)
|
|
1270
|
+
if holes: # heads are never knocked out (check_overlaps flags a head on text)
|
|
1271
|
+
buf.append("</g>")
|
|
1272
|
+
draw_head(buf, tip_end[0], tip_end[1], end_ang, eh, color=st.color, opacity=st.opacity,
|
|
1273
|
+
stroke_width=st.width)
|
|
1274
|
+
draw_head(buf, tip_start[0], tip_start[1], start_ang, sh, color=st.color,
|
|
1275
|
+
opacity=st.opacity, stroke_width=st.width)
|
|
1276
|
+
if self.label and not defer_label:
|
|
1277
|
+
self._draw_label(buf, pts, th, st, obstacles or [])
|
|
1278
|
+
|
|
1279
|
+
def endpoints(self) -> Tuple[Tuple[float, float, str], Tuple[float, float, str]]:
|
|
1280
|
+
"""Resolved (x, y, dir) of both ends; ``snap`` straightens facing side ports.
|
|
1281
|
+
|
|
1282
|
+
With ``snap=True`` and two plain side ports facing each other (bottom→top,
|
|
1283
|
+
right→left, …) whose nodes overlap on the cross axis, both ends move to the
|
|
1284
|
+
centre of that overlap so the connector is one straight segment.
|
|
1285
|
+
"""
|
|
1286
|
+
if getattr(self, "_bare_ends", False):
|
|
1287
|
+
self._pick_bare_sides()
|
|
1288
|
+
a = self.src.resolve()
|
|
1289
|
+
b = self.dst.resolve()
|
|
1290
|
+
sa = self.src.kind == "side" and self.src.side == "auto"
|
|
1291
|
+
sb = self.dst.kind == "side" and self.dst.side == "auto"
|
|
1292
|
+
if sa or sb:
|
|
1293
|
+
if sa and sb:
|
|
1294
|
+
ga, gb = self.src.node.geom, self.dst.node.geom
|
|
1295
|
+
a = auto_port(self.src.node, gb.cx, gb.cy)
|
|
1296
|
+
b = auto_port(self.dst.node, ga.cx, ga.cy)
|
|
1297
|
+
elif sa:
|
|
1298
|
+
a = _auto_in_line(self.src.node, b) or auto_port(self.src.node, b[0], b[1])
|
|
1299
|
+
else:
|
|
1300
|
+
b = _auto_in_line(self.dst.node, a) or auto_port(self.dst.node, a[0], a[1])
|
|
1301
|
+
return a, b
|
|
1302
|
+
if not self.snap:
|
|
1303
|
+
al = self._align_small_offset(a, b)
|
|
1304
|
+
if al is not None:
|
|
1305
|
+
return al
|
|
1306
|
+
if not self.snap or self.src.kind != "side" or self.dst.kind != "side":
|
|
1307
|
+
return a, b
|
|
1308
|
+
ga, gb = self.src.node.geom, self.dst.node.geom
|
|
1309
|
+
vert = {a[2], b[2]} == {"up", "down"}
|
|
1310
|
+
horiz = {a[2], b[2]} == {"left", "right"}
|
|
1311
|
+
if vert:
|
|
1312
|
+
lo, hi = max(ga.x, gb.x), min(ga.x + ga.w, gb.x + gb.w)
|
|
1313
|
+
if hi - lo > 2:
|
|
1314
|
+
x = min(max((a[0] + b[0]) / 2, lo + 1), hi - 1)
|
|
1315
|
+
return (on_outline(self.src.node, self.src.side, (x, a[1], a[2])),
|
|
1316
|
+
on_outline(self.dst.node, self.dst.side, (x, b[1], b[2])))
|
|
1317
|
+
elif horiz:
|
|
1318
|
+
lo, hi = max(ga.y, gb.y), min(ga.y + ga.h, gb.y + gb.h)
|
|
1319
|
+
if hi - lo > 2:
|
|
1320
|
+
y = min(max((a[1] + b[1]) / 2, lo + 1), hi - 1)
|
|
1321
|
+
return (on_outline(self.src.node, self.src.side, (a[0], y, a[2])),
|
|
1322
|
+
on_outline(self.dst.node, self.dst.side, (b[0], y, b[2])))
|
|
1323
|
+
return a, b
|
|
1324
|
+
|
|
1325
|
+
@staticmethod
|
|
1326
|
+
def _facing(a: Tuple[float, float, str], b: Tuple[float, float, str]) -> Optional[str]:
|
|
1327
|
+
"""``"h"`` / ``"v"`` when the two ends point at each other, else None."""
|
|
1328
|
+
if (a[2], b[2]) == ("right", "left") and b[0] > a[0] or \
|
|
1329
|
+
(a[2], b[2]) == ("left", "right") and b[0] < a[0]:
|
|
1330
|
+
return "h"
|
|
1331
|
+
if (a[2], b[2]) == ("down", "up") and b[1] > a[1] or \
|
|
1332
|
+
(a[2], b[2]) == ("up", "down") and b[1] < a[1]:
|
|
1333
|
+
return "v"
|
|
1334
|
+
return None
|
|
1335
|
+
|
|
1336
|
+
def _align_small_offset(self, a, b):
|
|
1337
|
+
"""v0.8.0: remove a tiny ortho jog between two facing ends.
|
|
1338
|
+
|
|
1339
|
+
A fixed anchor (a Cuboid face, a slab edge, …) facing a plain side port a few pt
|
|
1340
|
+
off-line slides the side port into line with it, so the connector is one straight
|
|
1341
|
+
segment; two plain facing side ports a few pt apart snap to their overlap.
|
|
1342
|
+
"""
|
|
1343
|
+
if self.route != "ortho":
|
|
1344
|
+
return None
|
|
1345
|
+
f = self._facing(a, b)
|
|
1346
|
+
if f is None:
|
|
1347
|
+
return None
|
|
1348
|
+
k = 1 if f == "h" else 0
|
|
1349
|
+
off = b[k] - a[k]
|
|
1350
|
+
if abs(off) < 0.05 or abs(off) > self.theme.L(10.0):
|
|
1351
|
+
return None
|
|
1352
|
+
|
|
1353
|
+
def slidable(p: Port) -> bool:
|
|
1354
|
+
return p.kind == "side" and p.side in ("left", "right", "top", "bottom") and \
|
|
1355
|
+
abs(p.at - 0.5) < 1e-9 and p.offset == 0
|
|
1356
|
+
|
|
1357
|
+
sa, sb = slidable(self.src), slidable(self.dst)
|
|
1358
|
+
if sa and sb:
|
|
1359
|
+
(a0, a1), (b0, b1) = self._span(self.src, k), self._span(self.dst, k)
|
|
1360
|
+
lo, hi = max(a0, b0), min(a1, b1)
|
|
1361
|
+
m = self.theme.L(2.0)
|
|
1362
|
+
if hi - lo <= 2 * m or abs(off) > self.theme.L(8.0):
|
|
1363
|
+
return None
|
|
1364
|
+
t = min(max((a[k] + b[k]) / 2, lo + m), hi - m)
|
|
1365
|
+
return self._slide(self.src, t, k), self._slide(self.dst, t, k)
|
|
1366
|
+
if sa != sb and (self.src.kind == "custom" if sb else self.dst.kind == "custom"):
|
|
1367
|
+
mov, fixed = (self.dst, a) if sb else (self.src, b)
|
|
1368
|
+
lo, hi = self._span(mov, k)
|
|
1369
|
+
span = hi - lo
|
|
1370
|
+
m = max(self.theme.L(2.0), 0.15 * span)
|
|
1371
|
+
if not (lo + m <= fixed[k] <= lo + span - m):
|
|
1372
|
+
return None
|
|
1373
|
+
p = self._slide(mov, fixed[k], k)
|
|
1374
|
+
return (a, p) if sb else (p, b)
|
|
1375
|
+
return None
|
|
1376
|
+
|
|
1377
|
+
@staticmethod
|
|
1378
|
+
def _span(p: Port, k: int) -> Tuple[float, float]:
|
|
1379
|
+
"""Extent of side port ``p`` along axis ``k`` (``at`` 0 → 1), as resolved."""
|
|
1380
|
+
u = Port(p.node, kind="side", side=p.side, at=0.0).resolve()[k]
|
|
1381
|
+
v = Port(p.node, kind="side", side=p.side, at=1.0).resolve()[k]
|
|
1382
|
+
return min(u, v), max(u, v)
|
|
1383
|
+
|
|
1384
|
+
@classmethod
|
|
1385
|
+
def _slide(cls, p: Port, t: float, k: int) -> Tuple[float, float, str]:
|
|
1386
|
+
lo, hi = cls._span(p, k)
|
|
1387
|
+
at = 0.5 if hi - lo < 1e-9 else (t - lo) / (hi - lo)
|
|
1388
|
+
q = Port(p.node, kind="side", side=p.side, at=at).resolve()
|
|
1389
|
+
# shapes whose side is not parametrised linearly (cylinder caps …): a few
|
|
1390
|
+
# secant steps so the slid point really lands on ``t``
|
|
1391
|
+
a0, v0 = 0.5, Port(p.node, kind="side", side=p.side, at=0.5).resolve()[k]
|
|
1392
|
+
for _ in range(6):
|
|
1393
|
+
if abs(q[k] - t) <= 0.01 or abs(q[k] - v0) < 1e-9:
|
|
1394
|
+
break
|
|
1395
|
+
a1 = at + (t - q[k]) * (at - a0) / (q[k] - v0)
|
|
1396
|
+
a0, v0 = at, q[k]
|
|
1397
|
+
at = min(1.0, max(0.0, a1))
|
|
1398
|
+
q = Port(p.node, kind="side", side=p.side, at=at).resolve()
|
|
1399
|
+
return q
|
|
1400
|
+
|
|
1401
|
+
def _tiny_jog(self) -> bool:
|
|
1402
|
+
"""Two fixed facing anchors a few pt off-line: one short slope beats a jog."""
|
|
1403
|
+
if self.route != "ortho":
|
|
1404
|
+
return False
|
|
1405
|
+
a, b = self.endpoints()
|
|
1406
|
+
f = self._facing(a, b)
|
|
1407
|
+
if f is None:
|
|
1408
|
+
return False
|
|
1409
|
+
k = 1 if f == "h" else 0
|
|
1410
|
+
off, along = abs(b[k] - a[k]), abs(b[1 - k] - a[1 - k])
|
|
1411
|
+
return 0.05 < off <= self.theme.L(4.0) and along >= 3.0 * off
|
|
1412
|
+
|
|
1413
|
+
def _pick_bare_sides(self) -> None:
|
|
1414
|
+
"""Facing sides for an Edge built from two bare nodes (v0.5.0)."""
|
|
1415
|
+
na, nb = self.src.node, self.dst.node
|
|
1416
|
+
ga, gb = na.geom, nb.geom
|
|
1417
|
+
if self.waypoints: # v0.14.0: face the first / last waypoint
|
|
1418
|
+
wp = self.waypoint_pts()
|
|
1419
|
+
sides = (_side_towards(ga, *wp[0]), _side_towards(gb, *wp[-1]))
|
|
1420
|
+
if (self.src.side, self.dst.side) != sides:
|
|
1421
|
+
self.src = Port(na, kind="side", side=sides[0])
|
|
1422
|
+
self.dst = Port(nb, kind="side", side=sides[1])
|
|
1423
|
+
return
|
|
1424
|
+
if gb.x >= ga.right - 0.5:
|
|
1425
|
+
sides = ("right", "left")
|
|
1426
|
+
elif gb.right <= ga.x + 0.5:
|
|
1427
|
+
sides = ("left", "right")
|
|
1428
|
+
elif gb.y >= ga.bottom - 0.5:
|
|
1429
|
+
sides = ("bottom", "top")
|
|
1430
|
+
elif gb.bottom <= ga.y + 0.5:
|
|
1431
|
+
sides = ("top", "bottom")
|
|
1432
|
+
else:
|
|
1433
|
+
sides = ("right", "left")
|
|
1434
|
+
if (self.src.side, self.dst.side) != sides:
|
|
1435
|
+
self.src = Port(na, kind="side", side=sides[0])
|
|
1436
|
+
self.dst = Port(nb, kind="side", side=sides[1])
|
|
1437
|
+
|
|
1438
|
+
def waypoint_pts(self) -> List[Tuple[float, float, str]]:
|
|
1439
|
+
"""v0.14.0: ``(x, y, side)`` of each ``via=(node, side)`` waypoint — just outside
|
|
1440
|
+
that side of the node (clear of its routing padding), at the side's centre."""
|
|
1441
|
+
out = []
|
|
1442
|
+
for node, side in self.waypoints:
|
|
1443
|
+
g = node.geom
|
|
1444
|
+
gap = 5.0 + self.theme.L(3.0)
|
|
1445
|
+
x, y = {"top": (g.cx, g.y - gap), "bottom": (g.cx, g.bottom + gap),
|
|
1446
|
+
"left": (g.x - gap, g.cy), "right": (g.right + gap, g.cy)}[side]
|
|
1447
|
+
out.append((x, y, side))
|
|
1448
|
+
return out
|
|
1449
|
+
|
|
1450
|
+
# ---- label placement ------------------------------------------------------
|
|
1451
|
+
def _custom_label(self) -> bool:
|
|
1452
|
+
return any(
|
|
1453
|
+
v not in (None, False)
|
|
1454
|
+
for v in (self.label_at, self.label_side, self.label_bg, self.label_size,
|
|
1455
|
+
self.label_color, self.label_gap)
|
|
1456
|
+
) or bool(self.label_rotate) or self.route == "curve" or self.step is not None
|
|
1457
|
+
|
|
1458
|
+
def _label_fs(self, th: Theme) -> float:
|
|
1459
|
+
return self.label_size if self.label_size is not None else th.font_size * 0.85
|
|
1460
|
+
|
|
1461
|
+
def label_geometry(
|
|
1462
|
+
self,
|
|
1463
|
+
pts: List[Pt],
|
|
1464
|
+
theme: Optional[Theme] = None,
|
|
1465
|
+
obstacles: Optional[List[Tuple[float, float, float, float]]] = None,
|
|
1466
|
+
paths: Optional[Sequence[Sequence[Pt]]] = None,
|
|
1467
|
+
) -> Tuple[Tuple[float, float, float, float], float, str]:
|
|
1468
|
+
"""(bbox x, y, w, h), rotation, text align of the label on routed ``pts``.
|
|
1469
|
+
|
|
1470
|
+
``obstacles`` (boxes) and ``paths`` (other edges) steer ``label_side='auto'``.
|
|
1471
|
+
"""
|
|
1472
|
+
import math
|
|
1473
|
+
|
|
1474
|
+
th = theme or self.theme
|
|
1475
|
+
fs = self._label_fs(th)
|
|
1476
|
+
gap = self.label_gap if self.label_gap is not None else th.edge_label_gap * 0.6
|
|
1477
|
+
if (self._custom_label() or getattr(self, "_label_unlegacy", False)) and self.route != "curve":
|
|
1478
|
+
pts = _merge_collinear(pts)
|
|
1479
|
+
segs = [(pts[i], pts[i + 1]) for i in range(len(pts) - 1)]
|
|
1480
|
+
lens = [math.hypot(b[0] - a[0], b[1] - a[1]) for a, b in segs]
|
|
1481
|
+
total = sum(lens) or 1.0
|
|
1482
|
+
legacy = not self._custom_label() and not getattr(self, "_label_unlegacy", False)
|
|
1483
|
+
at = self.label_at if self.label_at is not None else "mid"
|
|
1484
|
+
if legacy:
|
|
1485
|
+
k = min(len(pts) // 2, len(segs) - 1)
|
|
1486
|
+
a, b = segs[k]
|
|
1487
|
+
px, py = pts[len(pts) // 2]
|
|
1488
|
+
elif isinstance(at, (int, float)) or (at == "mid" and self.route == "curve"):
|
|
1489
|
+
frac = 0.5 if isinstance(at, str) else float(at)
|
|
1490
|
+
target, acc, k = frac * total, 0.0, 0
|
|
1491
|
+
for k, L in enumerate(lens):
|
|
1492
|
+
if acc + L >= target or k == len(lens) - 1:
|
|
1493
|
+
break
|
|
1494
|
+
acc += L
|
|
1495
|
+
a, b = segs[k]
|
|
1496
|
+
t = 0.0 if lens[k] == 0 else min(1.0, max(0.0, (target - acc) / lens[k]))
|
|
1497
|
+
px, py = a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t
|
|
1498
|
+
else:
|
|
1499
|
+
idx = list(range(len(segs)))
|
|
1500
|
+
longish = [i for i in idx if lens[i] > max(self.stub + 1.0, 0.15 * max(lens))]
|
|
1501
|
+
if at == "longest":
|
|
1502
|
+
k = max(idx, key=lambda i: lens[i])
|
|
1503
|
+
elif at == "start":
|
|
1504
|
+
k = (longish or idx)[0]
|
|
1505
|
+
elif at == "end":
|
|
1506
|
+
k = (longish or idx)[-1]
|
|
1507
|
+
else: # mid: middle of the path by length
|
|
1508
|
+
target, acc, k = total / 2, 0.0, 0
|
|
1509
|
+
for k, L in enumerate(lens):
|
|
1510
|
+
if acc + L >= target:
|
|
1511
|
+
break
|
|
1512
|
+
acc += L
|
|
1513
|
+
a, b = segs[k]
|
|
1514
|
+
if at == "mid":
|
|
1515
|
+
t = 0.0 if lens[k] == 0 else min(1.0, max(0.0, (total / 2 - acc) / lens[k]))
|
|
1516
|
+
else:
|
|
1517
|
+
t = 0.5
|
|
1518
|
+
px, py = a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t
|
|
1519
|
+
vertical = abs(b[0] - a[0]) < abs(b[1] - a[1])
|
|
1520
|
+
rot = -90.0 if (vertical and self.label_rotate) else 0.0
|
|
1521
|
+
tw, tht = measure_text(self.label or "", fs)
|
|
1522
|
+
bw, bh = (tht, tw) if rot else (tw, tht)
|
|
1523
|
+
# a curve bulges away from its chord: push the label off the local tangent
|
|
1524
|
+
# direction by the box half-extent so it never sits on the stroke
|
|
1525
|
+
dx, dy = b[0] - a[0], b[1] - a[1]
|
|
1526
|
+
dn = math.hypot(dx, dy) or 1.0
|
|
1527
|
+
diag = self.route == "curve" and min(abs(dx), abs(dy)) / dn > 0.3
|
|
1528
|
+
|
|
1529
|
+
def box(side: str, px: float = px, py: float = py) -> Tuple[float, float, float, float]:
|
|
1530
|
+
if side == "on":
|
|
1531
|
+
return (px - bw / 2, py - bh / 2, bw, bh)
|
|
1532
|
+
if diag:
|
|
1533
|
+
# offset along the normal so the nearest box corner clears the curve
|
|
1534
|
+
nx, ny = -dy / dn, dx / dn
|
|
1535
|
+
if (side in ("above", "left") and ny > 0) or (side in ("below", "right") and ny < 0):
|
|
1536
|
+
nx, ny = -nx, -ny
|
|
1537
|
+
reach = abs(nx) * bw / 2 + abs(ny) * bh / 2 + gap
|
|
1538
|
+
cx, cy = px + nx * reach, py + ny * reach
|
|
1539
|
+
return (cx - bw / 2, cy - bh / 2, bw, bh)
|
|
1540
|
+
if side == "above":
|
|
1541
|
+
return (px - bw / 2, py - gap - bh, bw, bh)
|
|
1542
|
+
if side == "below":
|
|
1543
|
+
return (px - bw / 2, py + gap, bw, bh)
|
|
1544
|
+
if side == "left":
|
|
1545
|
+
return (px - gap - bw, py - bh / 2, bw, bh)
|
|
1546
|
+
return (px + gap, py - bh / 2, bw, bh) # right
|
|
1547
|
+
|
|
1548
|
+
obs = obstacles or []
|
|
1549
|
+
others = paths or []
|
|
1550
|
+
|
|
1551
|
+
def cost(bx_: Tuple[float, float, float, float]) -> float:
|
|
1552
|
+
x, y, w, h = bx_
|
|
1553
|
+
c = 0.0
|
|
1554
|
+
for ox_, oy_, ow, oh in obs:
|
|
1555
|
+
ix = min(x + w, ox_ + ow) - max(x, ox_)
|
|
1556
|
+
iy = min(y + h, oy_ + oh) - max(y, oy_)
|
|
1557
|
+
if ix > 0 and iy > 0:
|
|
1558
|
+
c += ix * iy
|
|
1559
|
+
for p in others:
|
|
1560
|
+
if len(p) >= 2 and _poly_hits_rect(p, (x - 1, y - 1, w + 2, h + 2)):
|
|
1561
|
+
c += 60.0
|
|
1562
|
+
# v0.12: the label must not sit on another segment of its own edge (a
|
|
1563
|
+
# label beside a short jog used to be struck by the next leg)
|
|
1564
|
+
if own_check and len(pts) >= 2 and side != "on" and \
|
|
1565
|
+
_poly_hits_rect(pts, (x + 0.5, y + 0.5, max(0.0, w - 1), max(0.0, h - 1))):
|
|
1566
|
+
c += 80.0
|
|
1567
|
+
return c
|
|
1568
|
+
|
|
1569
|
+
side = "above" if legacy else (self.label_side or "auto")
|
|
1570
|
+
own_check = not legacy and self.route != "curve"
|
|
1571
|
+
sides = ([side] if side != "auto" else
|
|
1572
|
+
(["right", "left"] if vertical else ["above", "below"]))
|
|
1573
|
+
# candidate anchor points: the requested one, then (for named label_at
|
|
1574
|
+
# positions) slides along the same segment while the label still fits on it
|
|
1575
|
+
anchors = [(px, py, 0.0)]
|
|
1576
|
+
slide = (not legacy and not isinstance(at, (int, float)) and self.route != "curve"
|
|
1577
|
+
and self.label_side != "on")
|
|
1578
|
+
if slide and (obs or others or (own_check and any(
|
|
1579
|
+
cost(box(sd)) > 0 for sd in sides))):
|
|
1580
|
+
seg_len = math.hypot(b[0] - a[0], b[1] - a[1])
|
|
1581
|
+
along = bh if vertical else bw
|
|
1582
|
+
room = (seg_len - along) / 2 - 2.0
|
|
1583
|
+
if room > 2.0:
|
|
1584
|
+
ux, uy = (b[0] - a[0]) / seg_len, (b[1] - a[1]) / seg_len
|
|
1585
|
+
mx, my = (a[0] + b[0]) / 2, (a[1] + b[1]) / 2
|
|
1586
|
+
for f in (0.25, -0.25, 0.5, -0.5, 0.75, -0.75, 1.0, -1.0):
|
|
1587
|
+
q = f * room
|
|
1588
|
+
anchors.append((mx + ux * q, my + uy * q, abs(q)))
|
|
1589
|
+
best = None
|
|
1590
|
+
for (ax_, ay_, shift) in anchors:
|
|
1591
|
+
for sd in sides:
|
|
1592
|
+
bb = box(sd, ax_, ay_)
|
|
1593
|
+
c = cost(bb) + 0.05 * shift
|
|
1594
|
+
if best is None or c < best[0] - 1e-9:
|
|
1595
|
+
best = (c, sd, bb)
|
|
1596
|
+
if best is not None and best[0] < 1e-6:
|
|
1597
|
+
break
|
|
1598
|
+
if own_check and best is not None and best[0] >= 80.0 and len(segs) >= 2 and not rot:
|
|
1599
|
+
# v0.12: every spot on the chosen leg sits on another leg of this edge —
|
|
1600
|
+
# try the middle of the other legs (nearest first)
|
|
1601
|
+
for j in sorted(range(len(segs)), key=lambda j: abs(j - k)):
|
|
1602
|
+
if j == k or lens[j] < 1.0:
|
|
1603
|
+
continue
|
|
1604
|
+
(qa, qb) = segs[j]
|
|
1605
|
+
qv = abs(qb[0] - qa[0]) < abs(qb[1] - qa[1])
|
|
1606
|
+
mx, my = (qa[0] + qb[0]) / 2, (qa[1] + qb[1]) / 2
|
|
1607
|
+
ux, uy = (qb[0] - qa[0]) / lens[j], (qb[1] - qa[1]) / lens[j]
|
|
1608
|
+
room = max(0.0, (lens[j] - (bh if qv else bw)) / 2 - 2.0)
|
|
1609
|
+
for f in (0.0, 0.25, -0.25, 0.5, -0.5, 0.75, -0.75, 1.0, -1.0):
|
|
1610
|
+
if f and room <= 2.0:
|
|
1611
|
+
break
|
|
1612
|
+
ax_, ay_ = mx + ux * f * room, my + uy * f * room
|
|
1613
|
+
for sd in (["right", "left"] if qv else ["above", "below"]):
|
|
1614
|
+
if self.label_side not in (None, "auto") and sd != self.label_side:
|
|
1615
|
+
continue
|
|
1616
|
+
bb = box(sd, ax_, ay_)
|
|
1617
|
+
c = cost(bb) + 0.02 * abs(j - k) + 0.05 * abs(f * room)
|
|
1618
|
+
if c < best[0] - 1e-9:
|
|
1619
|
+
best = (c, sd, bb)
|
|
1620
|
+
if best[0] < 1.0:
|
|
1621
|
+
break
|
|
1622
|
+
_c, side, bbox = best # type: ignore[misc]
|
|
1623
|
+
if legacy and len(pts) >= 2 and _c >= 1.0 and not rot:
|
|
1624
|
+
# v0.12.0: the classic mid-point label sits on a frame, another edge or a
|
|
1625
|
+
# box — let it slide along its leg like an explicit label_at='mid'
|
|
1626
|
+
self._label_unlegacy = True
|
|
1627
|
+
try:
|
|
1628
|
+
return self.label_geometry(pts, theme, obstacles, paths)
|
|
1629
|
+
finally:
|
|
1630
|
+
self._label_unlegacy = False
|
|
1631
|
+
if legacy and len(pts) >= 2: # v0.12.0: also a 2-point (vertical) edge
|
|
1632
|
+
x, y, w, h = bbox
|
|
1633
|
+
if _poly_hits_rect(pts, (x + 0.5, y + 0.5, max(0.0, w - 1), max(0.0, h - 1))):
|
|
1634
|
+
# v0.12: the classic mid-point label would sit on its own edge's next
|
|
1635
|
+
# leg — place it like an explicit label_at='mid' instead
|
|
1636
|
+
self._label_unlegacy = True
|
|
1637
|
+
try:
|
|
1638
|
+
return self.label_geometry(pts, theme, obstacles, paths)
|
|
1639
|
+
finally:
|
|
1640
|
+
self._label_unlegacy = False
|
|
1641
|
+
align = "center"
|
|
1642
|
+
if side == "left" and not rot and not diag:
|
|
1643
|
+
align = "right"
|
|
1644
|
+
elif side == "right" and not rot and not diag:
|
|
1645
|
+
align = "left"
|
|
1646
|
+
return bbox, rot, align
|
|
1647
|
+
|
|
1648
|
+
def step_geometry(
|
|
1649
|
+
self,
|
|
1650
|
+
pts: List[Pt],
|
|
1651
|
+
obstacles: Optional[Sequence[Tuple[float, float, float, float]]] = None,
|
|
1652
|
+
) -> Optional[Tuple[float, float, float]]:
|
|
1653
|
+
"""Centre (x, y) and diameter of this edge's step badge on routed ``pts``.
|
|
1654
|
+
|
|
1655
|
+
``step_at`` picks the nominal spot (fraction of the path length, or
|
|
1656
|
+
start / mid / end); if that spot covers an obstacle box the badge slides
|
|
1657
|
+
along the path to the nearest free spot. None when the edge has no step.
|
|
1658
|
+
"""
|
|
1659
|
+
import math
|
|
1660
|
+
|
|
1661
|
+
if self.step is None or len(pts) < 2:
|
|
1662
|
+
return None
|
|
1663
|
+
d = self.step.diameter()
|
|
1664
|
+
lens = [math.hypot(pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1])
|
|
1665
|
+
for i in range(len(pts) - 1)]
|
|
1666
|
+
total = sum(lens) or 1.0
|
|
1667
|
+
|
|
1668
|
+
def at(dist: float) -> Pt:
|
|
1669
|
+
dist = min(max(dist, 0.0), total)
|
|
1670
|
+
acc = 0.0
|
|
1671
|
+
for i, L in enumerate(lens):
|
|
1672
|
+
if acc + L >= dist or i == len(lens) - 1:
|
|
1673
|
+
t = 0.0 if L == 0 else (dist - acc) / L
|
|
1674
|
+
a, b = pts[i], pts[i + 1]
|
|
1675
|
+
return a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t
|
|
1676
|
+
acc += L
|
|
1677
|
+
return pts[-1]
|
|
1678
|
+
|
|
1679
|
+
margin = d / 2 + 4.0
|
|
1680
|
+
lo, hi = margin, max(margin, total - margin - 6.0) # keep clear of the head
|
|
1681
|
+
sa = self.step_at
|
|
1682
|
+
if sa == "start":
|
|
1683
|
+
nominal = lo + d * 0.3
|
|
1684
|
+
elif sa == "end":
|
|
1685
|
+
nominal = hi - d * 0.3
|
|
1686
|
+
elif sa == "mid":
|
|
1687
|
+
nominal = total / 2
|
|
1688
|
+
else:
|
|
1689
|
+
nominal = float(sa) * total
|
|
1690
|
+
nominal = min(max(nominal, lo), hi)
|
|
1691
|
+
obs = list(obstacles or [])
|
|
1692
|
+
|
|
1693
|
+
def cost(dist: float) -> float:
|
|
1694
|
+
x, y = at(dist)
|
|
1695
|
+
c = 0.0
|
|
1696
|
+
for ox_, oy_, ow, oh in obs:
|
|
1697
|
+
ix = min(x + d / 2, ox_ + ow) - max(x - d / 2, ox_)
|
|
1698
|
+
iy = min(y + d / 2, oy_ + oh) - max(y - d / 2, oy_)
|
|
1699
|
+
if ix > 0 and iy > 0:
|
|
1700
|
+
c += ix * iy
|
|
1701
|
+
return c
|
|
1702
|
+
|
|
1703
|
+
best, bc = nominal, cost(nominal)
|
|
1704
|
+
if bc > 0 and hi > lo:
|
|
1705
|
+
n = 24
|
|
1706
|
+
for k in range(1, n + 1):
|
|
1707
|
+
for sgn in (1, -1):
|
|
1708
|
+
dist = nominal + sgn * (hi - lo) * k / n
|
|
1709
|
+
if lo <= dist <= hi:
|
|
1710
|
+
c = cost(dist) + 0.01 * abs(dist - nominal)
|
|
1711
|
+
if c < bc:
|
|
1712
|
+
best, bc = dist, c
|
|
1713
|
+
if bc < 1.0:
|
|
1714
|
+
break
|
|
1715
|
+
x, y = at(best)
|
|
1716
|
+
return x, y, d
|
|
1717
|
+
|
|
1718
|
+
def _draw_label(self, buf, pts, th, st, obstacles, geometry=None) -> None:
|
|
1719
|
+
from .primitives import Text
|
|
1720
|
+
from .render_svg import draw_rect
|
|
1721
|
+
|
|
1722
|
+
(x, y, w, h), rot, align = geometry or self.label_geometry(pts, th, obstacles)
|
|
1723
|
+
fs = self._label_fs(th)
|
|
1724
|
+
bg = self.label_bg if self.label_bg is not None else None
|
|
1725
|
+
if bg and bg != "none":
|
|
1726
|
+
draw_rect(buf, x - 3, y - 1, w + 6, h + 2, fill=bg, stroke="none", stroke_width=0,
|
|
1727
|
+
radius=2)
|
|
1728
|
+
t = Text(self.label, font_size=fs, color=self.label_color or st.color, align=align,
|
|
1729
|
+
rotate=rot, theme=th)
|
|
1730
|
+
t.geom = BoxGeom(x, y, w, h)
|
|
1731
|
+
t.draw(buf)
|
|
1732
|
+
|
|
1733
|
+
|
|
1734
|
+
class Fork:
|
|
1735
|
+
"""
|
|
1736
|
+
Fan-out (``Fork(src, [dst, …])``) or fan-in (``Join([src, …], dst)``) as one
|
|
1737
|
+
orthogonal tree: a trunk, a shared bus at a branch point and one branch per
|
|
1738
|
+
target — the TikZ "|-" / draw.io tree look without hand-placed waypoints.
|
|
1739
|
+
|
|
1740
|
+
``at`` — where the bus sits between the source side and the nearest target
|
|
1741
|
+
(fraction 0..1); the router shifts it if the bus would cross a hard node.
|
|
1742
|
+
``dot=True`` marks the branch point. Style kwargs (color, width, style, head,
|
|
1743
|
+
tail, head_size, hollow, …) are the same as :class:`Edge`; heads are drawn only
|
|
1744
|
+
where the tree meets its targets (fork) or its single destination (join).
|
|
1745
|
+
``leaf_styles`` — optional per-leaf style overrides (one dict or None per leaf)
|
|
1746
|
+
applied to that leaf's branch only, e.g. a dashed / coloured "evicted" branch.
|
|
1747
|
+
"""
|
|
1748
|
+
|
|
1749
|
+
def __init__(self, src: Union[Port, Node, Sequence[Union[Port, Node]]],
|
|
1750
|
+
dsts: Union[Port, Node, Sequence[Union[Port, Node]]], *, at: float = 0.5,
|
|
1751
|
+
dot: bool = False, dot_size: Optional[float] = None, join: bool = False,
|
|
1752
|
+
label: Optional[str] = None, labels: Optional[Sequence[Optional[str]]] = None,
|
|
1753
|
+
flow: Optional[str] = None,
|
|
1754
|
+
leaf_styles: Optional[Sequence[Optional[Dict[str, Any]]]] = None,
|
|
1755
|
+
**style: Any) -> None:
|
|
1756
|
+
many_src = isinstance(src, (list, tuple))
|
|
1757
|
+
many_dst = isinstance(dsts, (list, tuple))
|
|
1758
|
+
if join:
|
|
1759
|
+
srcs = list(src) if many_src else [src]
|
|
1760
|
+
dst_list = [dsts] if not many_dst else list(dsts)
|
|
1761
|
+
if len(dst_list) != 1:
|
|
1762
|
+
raise ValueError("Join(srcs, dst): exactly one destination")
|
|
1763
|
+
self.hub, self.leaves = dst_list[0], srcs
|
|
1764
|
+
else:
|
|
1765
|
+
if many_src:
|
|
1766
|
+
raise ValueError("Fork(src, dsts): exactly one source")
|
|
1767
|
+
self.hub, self.leaves = src, (list(dsts) if many_dst else [dsts])
|
|
1768
|
+
if not self.leaves:
|
|
1769
|
+
raise ValueError("Fork/Join needs at least one leaf")
|
|
1770
|
+
self.join = bool(join)
|
|
1771
|
+
self.at = float(at)
|
|
1772
|
+
self.dot = bool(dot)
|
|
1773
|
+
self.dot_size = dot_size
|
|
1774
|
+
self.label = label
|
|
1775
|
+
self.labels = list(labels) if labels is not None else None
|
|
1776
|
+
self.flow = flow
|
|
1777
|
+
self.leaf_styles = [dict(d or {}) for d in leaf_styles] if leaf_styles else None
|
|
1778
|
+
if self.leaf_styles and len(self.leaf_styles) != len(self.leaves):
|
|
1779
|
+
raise ValueError("leaf_styles needs one entry (dict or None) per leaf")
|
|
1780
|
+
style.pop("route", None)
|
|
1781
|
+
self.style = style
|
|
1782
|
+
self.legend = style.pop("legend", None)
|
|
1783
|
+
self._edges: List[Edge] = []
|
|
1784
|
+
self._dot_at: Optional[Pt] = None
|
|
1785
|
+
# style probe (validates kwargs early)
|
|
1786
|
+
Edge(self._node(self.hub), self._node(self.leaves[0]), **self._style_kw())
|
|
1787
|
+
|
|
1788
|
+
@staticmethod
|
|
1789
|
+
def _node(ref: Union[Port, Node]) -> Node:
|
|
1790
|
+
return ref.node if isinstance(ref, Port) else ref
|
|
1791
|
+
|
|
1792
|
+
def _style_kw(self) -> Dict[str, Any]:
|
|
1793
|
+
return dict(self.style)
|
|
1794
|
+
|
|
1795
|
+
def _flow(self) -> str:
|
|
1796
|
+
"""down|up|right|left from the hub towards the leaves."""
|
|
1797
|
+
if self.flow:
|
|
1798
|
+
return self.flow
|
|
1799
|
+
if isinstance(self.hub, Port) and self.hub.kind in ("side", "aligned"):
|
|
1800
|
+
d = self.hub.resolve()[2]
|
|
1801
|
+
if d in _DIRV:
|
|
1802
|
+
return d if not self.join else {"up": "down", "down": "up", "left": "right",
|
|
1803
|
+
"right": "left"}[d]
|
|
1804
|
+
hg = self._node(self.hub).geom
|
|
1805
|
+
cx = sum(self._node(l).geom.cx for l in self.leaves) / len(self.leaves)
|
|
1806
|
+
cy = sum(self._node(l).geom.cy for l in self.leaves) / len(self.leaves)
|
|
1807
|
+
dx, dy = cx - hg.cx, cy - hg.cy
|
|
1808
|
+
if self.join:
|
|
1809
|
+
dx, dy = -dx, -dy
|
|
1810
|
+
if abs(dy) >= abs(dx):
|
|
1811
|
+
return "down" if dy > 0 else "up"
|
|
1812
|
+
return "right" if dx > 0 else "left"
|
|
1813
|
+
|
|
1814
|
+
def build(self, obstacles: Sequence[Any] = ()) -> List[Tuple[Edge, List[Pt]]]:
|
|
1815
|
+
"""Expand into internal (Edge, points) pieces (trunk, bus, branches)."""
|
|
1816
|
+
from .routing import path_cost
|
|
1817
|
+
|
|
1818
|
+
flow = self._flow() # direction of travel source → targets
|
|
1819
|
+
# frame: v along the flow away from the hub, u across
|
|
1820
|
+
out_dir = flow if not self.join else {"up": "down", "down": "up", "left": "right",
|
|
1821
|
+
"right": "left"}[flow]
|
|
1822
|
+
# out_dir: direction from the hub towards the leaves
|
|
1823
|
+
if out_dir in ("down", "up"):
|
|
1824
|
+
sgn = 1.0 if out_dir == "down" else -1.0
|
|
1825
|
+
|
|
1826
|
+
def to_f(p: Pt) -> Pt:
|
|
1827
|
+
return p[0], sgn * p[1]
|
|
1828
|
+
|
|
1829
|
+
def from_f(u: float, v: float) -> Pt:
|
|
1830
|
+
return u, sgn * v
|
|
1831
|
+
hub_side, leaf_side = ("bottom", "top") if out_dir == "down" else ("top", "bottom")
|
|
1832
|
+
else:
|
|
1833
|
+
sgn = 1.0 if out_dir == "right" else -1.0
|
|
1834
|
+
|
|
1835
|
+
def to_f(p: Pt) -> Pt:
|
|
1836
|
+
return p[1], sgn * p[0]
|
|
1837
|
+
|
|
1838
|
+
def from_f(u: float, v: float) -> Pt:
|
|
1839
|
+
return sgn * v, u
|
|
1840
|
+
hub_side, leaf_side = ("right", "left") if out_dir == "right" else ("left", "right")
|
|
1841
|
+
hub = _as_port(self.hub, hub_side) if isinstance(self.hub, Port) else \
|
|
1842
|
+
Port(self.hub, kind="side", side=hub_side)
|
|
1843
|
+
leaves = [l if isinstance(l, Port) else Port(l, kind="side", side=leaf_side)
|
|
1844
|
+
for l in self.leaves]
|
|
1845
|
+
hx, hy, _ = hub.resolve()
|
|
1846
|
+
hu, hv = to_f((hx, hy))
|
|
1847
|
+
lr = [l.resolve() for l in leaves]
|
|
1848
|
+
lf = [to_f((x, y)) for x, y, _d in lr]
|
|
1849
|
+
stub = 10.0
|
|
1850
|
+
near = min(v for _u, v in lf)
|
|
1851
|
+
lo, hi = hv + stub, near - stub
|
|
1852
|
+
if hi < lo:
|
|
1853
|
+
lo = hi = (hv + near) / 2
|
|
1854
|
+
us = [u for u, _v in lf] + [hu]
|
|
1855
|
+
u0, u1 = min(us), max(us)
|
|
1856
|
+
|
|
1857
|
+
def bus_pts(vj: float) -> List[Pt]:
|
|
1858
|
+
return [from_f(u0, vj), from_f(u1, vj)]
|
|
1859
|
+
|
|
1860
|
+
best_v, best_c = None, None
|
|
1861
|
+
for f in (self.at, 0.5, 0.35, 0.65, 0.2, 0.8, 0.1, 0.9):
|
|
1862
|
+
vj = lo + (hi - lo) * max(0.0, min(1.0, f))
|
|
1863
|
+
pts = [from_f(hu, hv), from_f(hu, vj)] + bus_pts(vj)
|
|
1864
|
+
c = path_cost(pts[:2], obstacles) + path_cost(pts[2:], obstacles) if obstacles else 0.0
|
|
1865
|
+
if best_c is None or c < best_c - 1e-6:
|
|
1866
|
+
best_v, best_c = vj, c
|
|
1867
|
+
if c == 0:
|
|
1868
|
+
break
|
|
1869
|
+
vj = float(best_v)
|
|
1870
|
+
kw = self._style_kw()
|
|
1871
|
+
hub_node = self._node(self.hub)
|
|
1872
|
+
pieces: List[Tuple[Edge, List[Pt]]] = []
|
|
1873
|
+
|
|
1874
|
+
def piece(a: Any, b: Any, pts: List[Pt], head: bool, label: Optional[str] = None,
|
|
1875
|
+
extra: Optional[Dict[str, Any]] = None) -> None:
|
|
1876
|
+
k2 = dict(kw, **(extra or {}))
|
|
1877
|
+
e = Edge(a, b, label=label, **{k: v for k, v in k2.items()
|
|
1878
|
+
if k not in ("head", "tail", "marker_end", "marker_start")},
|
|
1879
|
+
head=k2.get("head") if head else "none", tail="none")
|
|
1880
|
+
pieces.append((e, _dedupe(pts)))
|
|
1881
|
+
|
|
1882
|
+
trunk = [from_f(hu, hv), from_f(hu, vj)]
|
|
1883
|
+
if self.join:
|
|
1884
|
+
trunk = trunk[::-1]
|
|
1885
|
+
bus = bus_pts(vj)
|
|
1886
|
+
branches = []
|
|
1887
|
+
for (lx, ly, ld), (lu, lv), leaf in zip(lr, lf, leaves):
|
|
1888
|
+
dv = _DIRV.get(ld, (0.0, 0.0))
|
|
1889
|
+
du_, dvv = to_f(dv)
|
|
1890
|
+
if abs(du_) > 0.5: # leaf port faces sideways: go along the bus past it, then in
|
|
1891
|
+
bu = lu + (stub if du_ > 0 else -stub)
|
|
1892
|
+
pts = [from_f(bu, vj), from_f(bu, lv), from_f(lu, lv)]
|
|
1893
|
+
else:
|
|
1894
|
+
pts = [from_f(lu, vj), from_f(lu, lv)]
|
|
1895
|
+
branches.append((leaf, pts))
|
|
1896
|
+
# extend the bus to sideways branch columns
|
|
1897
|
+
allu = [to_f(p[0])[0] for _l, p in branches] + [hu]
|
|
1898
|
+
bus = [from_f(min(allu), vj), from_f(max(allu), vj)]
|
|
1899
|
+
leaf_labels = self.labels or [None] * len(branches)
|
|
1900
|
+
leaf_kw = self.leaf_styles or [None] * len(branches)
|
|
1901
|
+
if self.join:
|
|
1902
|
+
for (leaf, pts), lab, xk in zip(branches, leaf_labels, leaf_kw):
|
|
1903
|
+
piece(leaf.node, hub_node, pts[::-1], head=False, label=lab, extra=xk)
|
|
1904
|
+
if abs(bus[0][0] - bus[1][0]) + abs(bus[0][1] - bus[1][1]) > 0.5:
|
|
1905
|
+
piece(hub_node, hub_node, bus, head=False)
|
|
1906
|
+
piece(hub_node, hub_node, trunk, head=True, label=self.label)
|
|
1907
|
+
else:
|
|
1908
|
+
piece(hub_node, hub_node, trunk, head=False, label=self.label)
|
|
1909
|
+
if abs(bus[0][0] - bus[1][0]) + abs(bus[0][1] - bus[1][1]) > 0.5:
|
|
1910
|
+
piece(hub_node, hub_node, bus, head=False)
|
|
1911
|
+
for (leaf, pts), lab, xk in zip(branches, leaf_labels, leaf_kw):
|
|
1912
|
+
piece(hub_node, leaf.node, pts, head=True, label=lab, extra=xk)
|
|
1913
|
+
for e, _p in pieces:
|
|
1914
|
+
e._fork = self # type: ignore[attr-defined]
|
|
1915
|
+
self._edges = [e for e, _p in pieces]
|
|
1916
|
+
self._dot_at = from_f(hu, vj) if self.dot else None
|
|
1917
|
+
return pieces
|
|
1918
|
+
|
|
1919
|
+
|
|
1920
|
+
def Join(srcs: Sequence[Union[Port, Node]], dst: Union[Port, Node], **kw: Any) -> Fork:
|
|
1921
|
+
"""Fan-in: several sources merge on a shared bus, one arrow into ``dst``."""
|
|
1922
|
+
return Fork(srcs, dst, join=True, **kw)
|
|
1923
|
+
|
|
1924
|
+
|
|
1925
|
+
def _legs_of(e: Any) -> List[Any]:
|
|
1926
|
+
legs = getattr(e, "legs", None)
|
|
1927
|
+
return legs() if callable(legs) else [e]
|
|
1928
|
+
|
|
1929
|
+
|
|
1930
|
+
@dataclass
|
|
1931
|
+
class _CornerPort(Port):
|
|
1932
|
+
"""A side-corner port pushed ``zpad`` outwards on both axes (zoom outline corners)."""
|
|
1933
|
+
|
|
1934
|
+
zpad: float = 0.0
|
|
1935
|
+
|
|
1936
|
+
def resolve(self) -> Tuple[float, float, str]:
|
|
1937
|
+
x, y, d = Port.resolve(self)
|
|
1938
|
+
p, t = self.zpad, (self.at - 0.5) * 2 * self.zpad
|
|
1939
|
+
if self.side in ("left", "right"):
|
|
1940
|
+
return x + (p if self.side == "right" else -p), y + t, d
|
|
1941
|
+
return x + t, y + (p if self.side == "bottom" else -p), d
|
|
1942
|
+
|
|
1943
|
+
|
|
1944
|
+
class Guide(Edge):
|
|
1945
|
+
"""v0.12.0: a plain helper line between two ports — zoom-in guides, "same token"
|
|
1946
|
+
links, alignment hints, cross-panel correspondences.
|
|
1947
|
+
|
|
1948
|
+
A Guide is straight when clear and detours around text and hard nodes.
|
|
1949
|
+
It has **no arrowheads** and is exempt from CF-ARR-002 (it is meant to pass over things) and
|
|
1950
|
+
CF-STR-003 (its dash is a drawing convention, not a legend category).
|
|
1951
|
+
Its labels are still checked like any text. Use :class:`Edge` for arrows.
|
|
1952
|
+
|
|
1953
|
+
Args:
|
|
1954
|
+
src / dst: Ports or nodes (``node.port("right", at=0)`` for a corner).
|
|
1955
|
+
style: ``"dotted"`` (default), ``"dashed"`` or ``"solid"``.
|
|
1956
|
+
color: Line colour (default: the theme muted colour).
|
|
1957
|
+
width: Line width (default: the theme thin stroke).
|
|
1958
|
+
avoid_text: Detour around text and nodes (default True); False pins a straight guide.
|
|
1959
|
+
**kw: Further :class:`Edge` options (``label=``, ``opacity=`` …).
|
|
1960
|
+
|
|
1961
|
+
Example:
|
|
1962
|
+
>>> from codefig import Box, Guide
|
|
1963
|
+
>>> a, b = Box("a"), Box("b")
|
|
1964
|
+
>>> g = Guide(a.port("right", at=0), b.port("left", at=0), style="dashed")
|
|
1965
|
+
"""
|
|
1966
|
+
|
|
1967
|
+
def __init__(self, src: Any, dst: Any, *, style: str = "dotted", color: Optional[str] = None,
|
|
1968
|
+
width: Optional[float] = None, avoid_text: bool = True, **kw: Any) -> None:
|
|
1969
|
+
self.avoid_text = bool(avoid_text)
|
|
1970
|
+
from ._validate import check_enum
|
|
1971
|
+
style = check_enum("edges(style)", style, ("dotted", "dashed", "solid"))
|
|
1972
|
+
kw.setdefault("route", "straight")
|
|
1973
|
+
if kw["route"] != "straight":
|
|
1974
|
+
raise ValueError("a Guide is always straight; use Edge for routed connectors")
|
|
1975
|
+
kw.setdefault("head", "none")
|
|
1976
|
+
kw.setdefault("tail", "none")
|
|
1977
|
+
kw.setdefault("marker_end", False)
|
|
1978
|
+
kw.setdefault("overlap", "allow")
|
|
1979
|
+
kw.setdefault("stub", 0.0)
|
|
1980
|
+
kw.setdefault("align", False)
|
|
1981
|
+
kw.setdefault("trunk", False)
|
|
1982
|
+
kw.setdefault("knockout", False)
|
|
1983
|
+
super().__init__(src, dst, style=style, color=color, width=width, **kw)
|
|
1984
|
+
self.lint_ignore = ["CF-ARR-002", "CF-ARR-003", "CF-STR-003"]
|
|
1985
|
+
self._cf_guide_edge = True
|
|
1986
|
+
|
|
1987
|
+
def resolved(self, theme: Optional[Theme] = None) -> ResolvedEdgeStyle:
|
|
1988
|
+
th = theme or self.theme
|
|
1989
|
+
if self.color is None and self.stroke is None:
|
|
1990
|
+
self.color = getattr(th, "muted_color", "#777777")
|
|
1991
|
+
try:
|
|
1992
|
+
return super().resolved(theme)
|
|
1993
|
+
finally:
|
|
1994
|
+
self.color = None
|
|
1995
|
+
return super().resolved(theme)
|
|
1996
|
+
|
|
1997
|
+
|
|
1998
|
+
class Diagram(ConstraintScope, Node):
|
|
1999
|
+
"""
|
|
2000
|
+
Content + overlay edges. After layout: bundling → lane offsets → obstacle-aware
|
|
2001
|
+
ortho/loop routing. overlap='avoid' (theme default) separates parallel trunks.
|
|
2002
|
+
|
|
2003
|
+
``diagram.constrain(...)`` registers relational constraints; they are solved by
|
|
2004
|
+
the enclosing Panel after flex layout and *before* edges are routed.
|
|
2005
|
+
|
|
2006
|
+
v0.7.0: ``regions=[Region([a, b], label="pool"), …]`` draws translucent / dashed
|
|
2007
|
+
areas behind arbitrary members (they may overlap each other); see
|
|
2008
|
+
:class:`~codefig.regions.Region`. ``diagram.region(members, label, **kw)`` adds one.
|
|
2009
|
+
"""
|
|
2010
|
+
|
|
2011
|
+
def __init__(
|
|
2012
|
+
self,
|
|
2013
|
+
content: Node,
|
|
2014
|
+
edges: Optional[Sequence[Edge]] = None,
|
|
2015
|
+
*,
|
|
2016
|
+
regions: Optional[Sequence[Any]] = None,
|
|
2017
|
+
theme: Optional[Theme] = None,
|
|
2018
|
+
**kwargs: Any,
|
|
2019
|
+
) -> None:
|
|
2020
|
+
if theme is not None:
|
|
2021
|
+
kwargs.setdefault("theme", theme)
|
|
2022
|
+
super().__init__(**kwargs)
|
|
2023
|
+
self.content = content
|
|
2024
|
+
content.parent = self
|
|
2025
|
+
self.edges: List[Edge] = []
|
|
2026
|
+
for edge in edges or []:
|
|
2027
|
+
self.add_edge(edge)
|
|
2028
|
+
self.regions: List[Any] = list(regions or [])
|
|
2029
|
+
|
|
2030
|
+
def region(self, members: Sequence[Node], label: Optional[str] = None, **kw: Any) -> Any:
|
|
2031
|
+
"""Add and return a :class:`~codefig.regions.Region` around ``members``."""
|
|
2032
|
+
from .regions import Region
|
|
2033
|
+
|
|
2034
|
+
r = Region(members, label, **kw)
|
|
2035
|
+
self.regions.append(r)
|
|
2036
|
+
return r
|
|
2037
|
+
|
|
2038
|
+
def _layout_regions(self) -> None:
|
|
2039
|
+
if self.regions:
|
|
2040
|
+
from .regions import layout_regions
|
|
2041
|
+
|
|
2042
|
+
layout_regions(self, self.regions)
|
|
2043
|
+
|
|
2044
|
+
def add_edge(self, edge: Edge) -> "Diagram":
|
|
2045
|
+
"""Add an Edge/Fork, or place a spanned Brace beside its referenced node.
|
|
2046
|
+
|
|
2047
|
+
A Brace without a span belongs in the Row/Col content instead.
|
|
2048
|
+
"""
|
|
2049
|
+
from .primitives import Brace
|
|
2050
|
+
from .constraints import place
|
|
2051
|
+
if isinstance(edge, Brace):
|
|
2052
|
+
if edge.span is None:
|
|
2053
|
+
raise TypeError("Diagram edges: Brace requires span=; otherwise place it in Row/Col content")
|
|
2054
|
+
self.constrain(place(edge, edge.span[0], side=edge.side, offset=self.theme.L(2)))
|
|
2055
|
+
return self
|
|
2056
|
+
if not isinstance(edge, (Edge, Fork)):
|
|
2057
|
+
raise TypeError("Diagram edges expects Edge, Fork, or Brace(span=...)")
|
|
2058
|
+
self.edges.extend(_legs_of(edge))
|
|
2059
|
+
return self
|
|
2060
|
+
|
|
2061
|
+
def zoom(self, src: Node, dst: Node, *, side: str = "right", style: str = "dashed",
|
|
2062
|
+
color: Optional[str] = None, outline: bool = True,
|
|
2063
|
+
pad: Optional[float] = None) -> Tuple[Any, "Guide", "Guide"]:
|
|
2064
|
+
"""v0.12.0: "zoom-in" callout — outline ``src`` and draw two guide lines from
|
|
2065
|
+
its corners to the facing corners of ``dst`` (the enlarged detail view).
|
|
2066
|
+
|
|
2067
|
+
Args:
|
|
2068
|
+
src: The node being magnified (a cell, a block, a group).
|
|
2069
|
+
dst: The node that shows it enlarged.
|
|
2070
|
+
side: Where ``dst`` sits relative to ``src``: ``"right"`` (default),
|
|
2071
|
+
``"left"``, ``"below"`` or ``"above"``.
|
|
2072
|
+
style: Outline and guide dash: ``"dashed"`` (default), ``"dotted"``, ``"solid"``.
|
|
2073
|
+
color: Outline / guide colour (default: the theme muted colour).
|
|
2074
|
+
outline: Draw the outline around ``src`` (default True; ``False`` when
|
|
2075
|
+
``src`` already has a visible border).
|
|
2076
|
+
pad: Gap between ``src`` and its outline, in pt (default: 2 design units
|
|
2077
|
+
with an outline, 0 without); the guides start at the outline corners.
|
|
2078
|
+
|
|
2079
|
+
Returns:
|
|
2080
|
+
``(region_or_None, guide1, guide2)`` — all already added to this Diagram.
|
|
2081
|
+
|
|
2082
|
+
Example:
|
|
2083
|
+
>>> from codefig import Box, Row, Diagram
|
|
2084
|
+
>>> a, b = Box("x"), Box("detail")
|
|
2085
|
+
>>> d = Diagram(Row([a, b], gap=30))
|
|
2086
|
+
>>> r, g1, g2 = d.zoom(a, b)
|
|
2087
|
+
"""
|
|
2088
|
+
from ._validate import check_enum
|
|
2089
|
+
side = check_enum("edges(side)", side, ("right", "left", "below", "above"))
|
|
2090
|
+
rs = {"dashed": "dashed", "dotted": "dotted", "solid": "outline"}
|
|
2091
|
+
if style not in rs:
|
|
2092
|
+
raise ValueError("zoom style must be dashed|dotted|solid")
|
|
2093
|
+
r = None
|
|
2094
|
+
pd = float(pad) if pad is not None else (self.theme.L(2.0) if outline else 0.0)
|
|
2095
|
+
if outline:
|
|
2096
|
+
col = color or getattr(self.theme, "muted_color", "#777777")
|
|
2097
|
+
r = self.region([src], None, style=rs[style], pad=pd, radius=0.0, color=col,
|
|
2098
|
+
exclude="none")
|
|
2099
|
+
out, inn = {"right": ("right", "left"), "left": ("left", "right"),
|
|
2100
|
+
"below": ("bottom", "top"), "above": ("top", "bottom")}[side]
|
|
2101
|
+
g1 = Guide(_CornerPort(src, "side", out, at=0.0, zpad=pd), dst.port(inn, at=0.0),
|
|
2102
|
+
style=style, color=color)
|
|
2103
|
+
g2 = Guide(_CornerPort(src, "side", out, at=1.0, zpad=pd), dst.port(inn, at=1.0),
|
|
2104
|
+
style=style, color=color)
|
|
2105
|
+
self.edges += [g1, g2]
|
|
2106
|
+
return r, g1, g2
|
|
2107
|
+
|
|
2108
|
+
def __iadd__(self, other: Union[Edge, Node]) -> "Diagram":
|
|
2109
|
+
if isinstance(other, Edge):
|
|
2110
|
+
self.edges.extend(_legs_of(other))
|
|
2111
|
+
return self
|
|
2112
|
+
raise TypeError("Diagram += expects an Edge; set content in the constructor")
|
|
2113
|
+
|
|
2114
|
+
def children(self) -> Sequence[Node]:
|
|
2115
|
+
return (self.content,)
|
|
2116
|
+
|
|
2117
|
+
def _loop_margins(self) -> Tuple[float, float, float, float]:
|
|
2118
|
+
"""Extra space so route='loop' trunks are not clipped.
|
|
2119
|
+
|
|
2120
|
+
Uses loop_side (auto → right) — no port resolve during measure/layout.
|
|
2121
|
+
"""
|
|
2122
|
+
l = t = r = b = 0.0
|
|
2123
|
+
for e in self._plain_edges():
|
|
2124
|
+
if e.route != "loop":
|
|
2125
|
+
continue
|
|
2126
|
+
pad = e.clearance + e.stub + 12.0
|
|
2127
|
+
side = e.loop_side if e.loop_side != "auto" else "right"
|
|
2128
|
+
if side == "right":
|
|
2129
|
+
r = max(r, pad)
|
|
2130
|
+
elif side == "left":
|
|
2131
|
+
l = max(l, pad)
|
|
2132
|
+
elif side == "bottom":
|
|
2133
|
+
b = max(b, pad)
|
|
2134
|
+
else:
|
|
2135
|
+
t = max(t, pad)
|
|
2136
|
+
return l, t, r, b
|
|
2137
|
+
|
|
2138
|
+
def _margins(self) -> Tuple[float, float, float, float]:
|
|
2139
|
+
fit = getattr(self, "_fit_margins", None)
|
|
2140
|
+
return fit if fit is not None else self._loop_margins()
|
|
2141
|
+
|
|
2142
|
+
def edge_overflow(self, pad: float = 3.0) -> Tuple[float, float, float, float]:
|
|
2143
|
+
"""How far routed edges / labels stick out of the content box (l, t, r, b)."""
|
|
2144
|
+
lay = self.layout_edges()
|
|
2145
|
+
xs: List[float] = []
|
|
2146
|
+
ys: List[float] = []
|
|
2147
|
+
for _e, pts in lay["routed"]:
|
|
2148
|
+
xs += [p[0] for p in pts]
|
|
2149
|
+
ys += [p[1] for p in pts]
|
|
2150
|
+
for _e, _p, ((x, y, w, h), _r, _a) in lay["labels"]:
|
|
2151
|
+
xs += [x, x + w]
|
|
2152
|
+
ys += [y, y + h]
|
|
2153
|
+
for _e, (x, y, w, h) in lay.get("steps", []):
|
|
2154
|
+
xs += [x, x + w]
|
|
2155
|
+
ys += [y, y + h]
|
|
2156
|
+
if self.regions:
|
|
2157
|
+
self._layout_regions()
|
|
2158
|
+
for r in self.regions:
|
|
2159
|
+
bb = r.bbox
|
|
2160
|
+
if bb is not None:
|
|
2161
|
+
xs += [bb[0], bb[0] + bb[2]]
|
|
2162
|
+
ys += [bb[1], bb[1] + bb[3]]
|
|
2163
|
+
g = self.content.geom
|
|
2164
|
+
if not xs:
|
|
2165
|
+
return 0.0, 0.0, 0.0, 0.0
|
|
2166
|
+
return (max(0.0, g.x - min(xs) + pad), max(0.0, g.y - min(ys) + pad),
|
|
2167
|
+
max(0.0, max(xs) - g.right + pad), max(0.0, max(ys) - g.bottom + pad))
|
|
2168
|
+
|
|
2169
|
+
def _measure(self) -> Tuple[float, float]:
|
|
2170
|
+
w, h = self.content.measure()
|
|
2171
|
+
l, t, r, b = self._margins()
|
|
2172
|
+
return w + l + r, h + t + b
|
|
2173
|
+
|
|
2174
|
+
def _layout_children(self) -> None:
|
|
2175
|
+
g = self.geom
|
|
2176
|
+
l, t, r, b = self._margins()
|
|
2177
|
+
self.content.layout(g.x + l, g.y + t, max(1.0, g.w - l - r), max(1.0, g.h - t - b))
|
|
2178
|
+
|
|
2179
|
+
def _edge_meta(self) -> List[dict]:
|
|
2180
|
+
meta = []
|
|
2181
|
+
for edge in self._plain_edges():
|
|
2182
|
+
(x1, y1, d1), (x2, y2, d2) = edge.endpoints()
|
|
2183
|
+
st = edge.resolved(theme=self.theme)
|
|
2184
|
+
meta.append(
|
|
2185
|
+
{
|
|
2186
|
+
"edge": edge,
|
|
2187
|
+
"x1": x1, "y1": y1, "d1": d1,
|
|
2188
|
+
"x2": x2, "y2": y2, "d2": d2,
|
|
2189
|
+
"st": st,
|
|
2190
|
+
"bundle": edge.bundle,
|
|
2191
|
+
"bundle_style": edge.bundle_style,
|
|
2192
|
+
}
|
|
2193
|
+
)
|
|
2194
|
+
return meta
|
|
2195
|
+
|
|
2196
|
+
def _obstacles_for(self, edge: "Edge"):
|
|
2197
|
+
from .routing import ancestors_in, collect_obstacles
|
|
2198
|
+
|
|
2199
|
+
exclude = []
|
|
2200
|
+
try:
|
|
2201
|
+
exclude.append(edge.src.node)
|
|
2202
|
+
exclude.append(edge.dst.node)
|
|
2203
|
+
except Exception:
|
|
2204
|
+
pass
|
|
2205
|
+
transparent = ancestors_in(self.content, exclude)
|
|
2206
|
+
obs = collect_obstacles(self.content, exclude=exclude, pad=5.0, transparent=transparent)
|
|
2207
|
+
from .policy import role_of
|
|
2208
|
+
from .routing import ORect
|
|
2209
|
+
|
|
2210
|
+
pad = 4.0
|
|
2211
|
+
for o in getattr(self, "_cf_overlay_obstacles", None) or []:
|
|
2212
|
+
if any(o is x for x in exclude):
|
|
2213
|
+
continue
|
|
2214
|
+
g = o.geom
|
|
2215
|
+
role = role_of(o) or "hard"
|
|
2216
|
+
if g.w > 1 and g.h > 1 and role != "none":
|
|
2217
|
+
obs.append(ORect(g.x - pad, g.y - pad, g.w + 2 * pad, g.h + 2 * pad, role))
|
|
2218
|
+
return obs
|
|
2219
|
+
|
|
2220
|
+
def _is_back_edge(self, edge):
|
|
2221
|
+
"""Return whether an automatic edge travels upstream in its shared layout."""
|
|
2222
|
+
if edge.route != "auto":
|
|
2223
|
+
return False
|
|
2224
|
+
src, dst = edge.src.node, edge.dst.node
|
|
2225
|
+
ancestors = []
|
|
2226
|
+
node = src.parent
|
|
2227
|
+
while node is not None:
|
|
2228
|
+
ancestors.append(node)
|
|
2229
|
+
node = node.parent
|
|
2230
|
+
node = dst.parent
|
|
2231
|
+
while node is not None and node not in ancestors:
|
|
2232
|
+
node = node.parent
|
|
2233
|
+
a, b = src.geom, dst.geom
|
|
2234
|
+
# Cross-axis siblings occupy parallel lanes even if alignment shifts
|
|
2235
|
+
# their centres slightly along the main axis.
|
|
2236
|
+
if src.parent is dst.parent and src.parent is not None:
|
|
2237
|
+
horizontal = getattr(src.parent, "direction", "row") == "row"
|
|
2238
|
+
if horizontal and min(a.y+a.h, b.y+b.h) <= max(a.y,b.y):
|
|
2239
|
+
return False
|
|
2240
|
+
if not horizontal and min(a.x+a.w,b.x+b.w) <= max(a.x,b.x):
|
|
2241
|
+
return False
|
|
2242
|
+
if getattr(node, "direction", "row") == "row":
|
|
2243
|
+
return b.cx < a.cx - 1e-6
|
|
2244
|
+
return b.cy < a.cy - 1e-6
|
|
2245
|
+
|
|
2246
|
+
def _routed(self) -> Tuple[List[Tuple[Edge, List[Pt]]], List[dict]]:
|
|
2247
|
+
"""Return (edge, pts) list plus bus underlay descriptors."""
|
|
2248
|
+
from .routing import apply_bundling, route_channel, route_loop_avoid, route_ortho_avoid
|
|
2249
|
+
|
|
2250
|
+
gap = self.theme.edge_lane_gap
|
|
2251
|
+
meta = self._edge_meta()
|
|
2252
|
+
|
|
2253
|
+
# 1) Bundling first — shared trunk for same bus id
|
|
2254
|
+
bundled_pts, buses = apply_bundling(
|
|
2255
|
+
meta, lane_gap=gap, style=getattr(self.theme, "edge_bundle_style", "lanes"))
|
|
2256
|
+
|
|
2257
|
+
# 2) Lane offsets for non-bundled avoid/ortho edges
|
|
2258
|
+
base_for_lanes = []
|
|
2259
|
+
for i, m in enumerate(meta):
|
|
2260
|
+
e = m["edge"]
|
|
2261
|
+
if bundled_pts[i] is not None:
|
|
2262
|
+
base_for_lanes.append((e, bundled_pts[i]))
|
|
2263
|
+
else:
|
|
2264
|
+
base_for_lanes.append((e, e.points()))
|
|
2265
|
+
# Only apply lane avoidance to unbundled edges
|
|
2266
|
+
unbundled = [(e, p) for (e, p), bp in zip(base_for_lanes, bundled_pts) if bp is None]
|
|
2267
|
+
bundled_only = [(e, p) for (e, p), bp in zip(base_for_lanes, bundled_pts) if bp is not None]
|
|
2268
|
+
lanened = apply_overlap_avoidance(unbundled, lane_gap=gap, theme=self.theme)
|
|
2269
|
+
|
|
2270
|
+
# Rebuild in original order
|
|
2271
|
+
lane_map = {id(e): pts for e, pts in lanened}
|
|
2272
|
+
bundle_map = {id(e): pts for e, pts in bundled_only}
|
|
2273
|
+
ordered: List[Tuple[Edge, List[Pt]]] = []
|
|
2274
|
+
for m in meta:
|
|
2275
|
+
e = m["edge"]
|
|
2276
|
+
if id(e) in bundle_map:
|
|
2277
|
+
ordered.append((e, bundle_map[id(e)]))
|
|
2278
|
+
else:
|
|
2279
|
+
ordered.append((e, lane_map.get(id(e), e.points())))
|
|
2280
|
+
|
|
2281
|
+
# 3) Obstacle-aware adjust for ortho + loop
|
|
2282
|
+
final: List[Tuple[Edge, List[Pt]]] = []
|
|
2283
|
+
self._curves: Dict[int, Tuple[Pt, Pt, Pt, Pt]] = {}
|
|
2284
|
+
for e, pts in ordered:
|
|
2285
|
+
obstacles = self._obstacles_for(e)
|
|
2286
|
+
if getattr(e, "_cf_guide_edge", False) and e.avoid_text:
|
|
2287
|
+
from .routing import ORect
|
|
2288
|
+
# Guide geometry must remain continuous: route around text rather
|
|
2289
|
+
# than cutting holes out of the guide at draw time.
|
|
2290
|
+
obstacles = [ORect(*o) for o in obstacles if getattr(o, "role", "hard") != "background"]
|
|
2291
|
+
obstacles += [ORect(x - self.theme.L(2), y - self.theme.L(2),
|
|
2292
|
+
w + self.theme.L(4), h + self.theme.L(4))
|
|
2293
|
+
for x, y, w, h in self._title_boxes()]
|
|
2294
|
+
if any(_poly_hits_rect(pts, o) for o in obstacles):
|
|
2295
|
+
(x1, y1, d1), (x2, y2, d2) = e.endpoints()
|
|
2296
|
+
pts = route_ortho_avoid(x1, y1, d1, x2, y2, d2,
|
|
2297
|
+
stub=e.stub, obstacles=obstacles)
|
|
2298
|
+
final.append((e, pts))
|
|
2299
|
+
continue
|
|
2300
|
+
from .routing import _core
|
|
2301
|
+
blocked = any(getattr(o, "role", "hard") == "hard" and
|
|
2302
|
+
_poly_hits_rect(pts, _core(o)) for o in obstacles)
|
|
2303
|
+
if e.route == "straight" or (e._tiny_jog() and not blocked and e.route != "back" and not self._is_back_edge(e)):
|
|
2304
|
+
final.append((e, pts))
|
|
2305
|
+
continue
|
|
2306
|
+
if e.route == "curve":
|
|
2307
|
+
ctrl = e.curve_ctrl(self._obstacles_for(e))
|
|
2308
|
+
self._curves[id(e)] = ctrl
|
|
2309
|
+
final.append((e, bezier_sample(ctrl)))
|
|
2310
|
+
continue
|
|
2311
|
+
obstacles = self._obstacles_for(e)
|
|
2312
|
+
(x1, y1, d1), (x2, y2, d2) = e.endpoints()
|
|
2313
|
+
if e.route == "back" or self._is_back_edge(e):
|
|
2314
|
+
from .routing import route_back
|
|
2315
|
+
final.append((e, route_back(x1, y1, d1, x2, y2, d2,
|
|
2316
|
+
stub=e.stub, side=e.loop_side,
|
|
2317
|
+
clearance=e.loop_gap if e.loop_gap is not None else e.theme.gap * 0.6,
|
|
2318
|
+
obstacles=obstacles, bounds=self.content.geom,
|
|
2319
|
+
container_bounds=(e.src.node.parent.geom
|
|
2320
|
+
if e.src.node.parent is e.dst.node.parent and e.src.node.parent is not None
|
|
2321
|
+
else self.content.geom))))
|
|
2322
|
+
continue
|
|
2323
|
+
if e.route in ("over", "under"):
|
|
2324
|
+
final.append((e, route_channel(x1, y1, d1, x2, y2, d2, stub=e.stub,
|
|
2325
|
+
over=e.route == "over", obstacles=obstacles)))
|
|
2326
|
+
continue
|
|
2327
|
+
if e.waypoints:
|
|
2328
|
+
final.append((e, self._route_waypoints(e, (x1, y1, d1), (x2, y2, d2), obstacles)))
|
|
2329
|
+
continue
|
|
2330
|
+
if e.route == "loop" and e.around is not None:
|
|
2331
|
+
pts2 = route_loop_around(x1, y1, d1, x2, y2, d2, e)
|
|
2332
|
+
final.append((e, pts2))
|
|
2333
|
+
elif e.route == "loop":
|
|
2334
|
+
pts2 = route_loop_avoid(
|
|
2335
|
+
x1, y1, d1, x2, y2, d2,
|
|
2336
|
+
stub=e.stub, side=e.loop_side, clearance=e.clearance,
|
|
2337
|
+
obstacles=obstacles,
|
|
2338
|
+
)
|
|
2339
|
+
final.append((e, pts2))
|
|
2340
|
+
elif e.route in ("ortho", "auto"):
|
|
2341
|
+
from .routing import choose_clear_mid_x
|
|
2342
|
+
|
|
2343
|
+
if (
|
|
2344
|
+
e.bundle
|
|
2345
|
+
and len(pts) >= 4
|
|
2346
|
+
and d1 in ("left", "right")
|
|
2347
|
+
and d2 in ("left", "right")
|
|
2348
|
+
):
|
|
2349
|
+
trunk_x = pts[2][0]
|
|
2350
|
+
ax, ay = _stub(x1, y1, d1, e.stub)
|
|
2351
|
+
bx, by = _stub(x2, y2, d2, e.stub)
|
|
2352
|
+
mx = choose_clear_mid_x(ax, ay, bx, by, trunk_x, obstacles)
|
|
2353
|
+
pts2 = _dedupe(
|
|
2354
|
+
[(x1, y1), (ax, ay), (mx, ay), (mx, by), (bx, by), (x2, y2)]
|
|
2355
|
+
)
|
|
2356
|
+
elif (e.bundle and len(pts) >= 4 and d1 in ("up", "down")
|
|
2357
|
+
and d2 in ("up", "down")):
|
|
2358
|
+
from .routing import choose_clear_mid_y
|
|
2359
|
+
|
|
2360
|
+
trunk_y = pts[2][1]
|
|
2361
|
+
ax, ay = _stub(x1, y1, d1, e.stub)
|
|
2362
|
+
bx, by = _stub(x2, y2, d2, e.stub)
|
|
2363
|
+
my = choose_clear_mid_y(ax, ay, bx, by, trunk_y, obstacles)
|
|
2364
|
+
pts2 = _dedupe(
|
|
2365
|
+
[(x1, y1), (ax, ay), (ax, my), (bx, my), (bx, by), (x2, y2)]
|
|
2366
|
+
)
|
|
2367
|
+
elif e.bundle:
|
|
2368
|
+
pts2 = route_ortho_avoid(
|
|
2369
|
+
x1, y1, d1, x2, y2, d2,
|
|
2370
|
+
stub=e.stub, mid_bias=0.0, obstacles=obstacles,
|
|
2371
|
+
)
|
|
2372
|
+
else:
|
|
2373
|
+
mid_bias = 0.0
|
|
2374
|
+
if (
|
|
2375
|
+
len(pts) >= 4
|
|
2376
|
+
and d1 in ("left", "right")
|
|
2377
|
+
and d2 in ("left", "right")
|
|
2378
|
+
):
|
|
2379
|
+
ax, _ay = _stub(x1, y1, d1, e.stub)
|
|
2380
|
+
bx, _by = _stub(x2, y2, d2, e.stub)
|
|
2381
|
+
natural = _trunk0(ax, bx, d1, d2)
|
|
2382
|
+
mid_bias = pts[2][0] - natural
|
|
2383
|
+
elif len(pts) >= 4 and d1 in ("up", "down") and d2 in ("up", "down"):
|
|
2384
|
+
_ax, ay = _stub(x1, y1, d1, e.stub)
|
|
2385
|
+
_bx, by = _stub(x2, y2, d2, e.stub)
|
|
2386
|
+
mid_bias = pts[2][1] - _trunk0(ay, by, d1, d2)
|
|
2387
|
+
pts2 = route_ortho_avoid(
|
|
2388
|
+
x1, y1, d1, x2, y2, d2,
|
|
2389
|
+
stub=e.stub, mid_bias=mid_bias,
|
|
2390
|
+
obstacles=obstacles + _end_rects(e, x1, y1, d1, x2, y2, d2),
|
|
2391
|
+
)
|
|
2392
|
+
final.append((e, pts2))
|
|
2393
|
+
else:
|
|
2394
|
+
final.append((e, pts))
|
|
2395
|
+
final = self._dejog_all(final)
|
|
2396
|
+
for fork in self._forks():
|
|
2397
|
+
final.extend(fork.build(self._fork_obstacles(fork)))
|
|
2398
|
+
return final, buses
|
|
2399
|
+
|
|
2400
|
+
def _route_waypoints(self, e: "Edge", a: Tuple[float, float, str], b: Tuple[float, float, str],
|
|
2401
|
+
obstacles: list) -> List[Pt]:
|
|
2402
|
+
"""v0.14.0: ``Edge(via=(node, side))`` — an ortho route through points just
|
|
2403
|
+
outside the named sides; the route passes the node there (over / under /
|
|
2404
|
+
beside it) instead of finding its own way round."""
|
|
2405
|
+
from .routing import _simplify_ortho, route_ortho_avoid
|
|
2406
|
+
|
|
2407
|
+
wps = e.waypoint_pts()
|
|
2408
|
+
pts: List[Pt] = []
|
|
2409
|
+
cur = a
|
|
2410
|
+
for i, (wx, wy, side) in enumerate(wps):
|
|
2411
|
+
px, py = cur[0], cur[1]
|
|
2412
|
+
nx, ny = (wps[i + 1][:2] if i + 1 < len(wps) else (b[0], b[1]))
|
|
2413
|
+
if side in ("top", "bottom"): # the route crosses the waypoint horizontally
|
|
2414
|
+
d_in = "left" if px <= wx else "right"
|
|
2415
|
+
d_out = "right" if nx >= wx else "left"
|
|
2416
|
+
else:
|
|
2417
|
+
d_in = "up" if py <= wy else "down"
|
|
2418
|
+
d_out = "down" if ny >= wy else "up"
|
|
2419
|
+
seg = route_ortho_avoid(cur[0], cur[1], cur[2], wx, wy, d_in, stub=e.stub,
|
|
2420
|
+
obstacles=obstacles)
|
|
2421
|
+
pts.extend(seg if not pts else seg[1:])
|
|
2422
|
+
cur = (wx, wy, d_out)
|
|
2423
|
+
last = route_ortho_avoid(cur[0], cur[1], cur[2], b[0], b[1], b[2], stub=e.stub,
|
|
2424
|
+
obstacles=obstacles + _end_rects(e, a[0], a[1], a[2], *b))
|
|
2425
|
+
pts.extend(last[1:] if pts else last)
|
|
2426
|
+
return _simplify_ortho(_dedupe(pts))
|
|
2427
|
+
|
|
2428
|
+
def _dejog_all(self, routed: List[Tuple[Edge, List[Pt]]]) -> List[Tuple[Edge, List[Pt]]]:
|
|
2429
|
+
"""v0.9.0: tidy every orthogonal route after lanes / obstacle avoidance.
|
|
2430
|
+
|
|
2431
|
+
1. drop duplicate and collinear vertices, folding sub-point backtracks that
|
|
2432
|
+
lane shifts can leave next to a stub;
|
|
2433
|
+
2. a Z route whose only cross segment is tiny (≤ L(8)) — whatever the port
|
|
2434
|
+
sides — becomes one straight segment by sliding a *plain* side port (no
|
|
2435
|
+
``at=`` / ``offset=``) along its side, if the slid point stays on the side
|
|
2436
|
+
(≥ L(2) from its corners), the straight segment misses every hard obstacle
|
|
2437
|
+
and it does not run on top of another edge. v0.10.0: larger jogs and
|
|
2438
|
+
staircase (multi-jog) routes too, while the slid port stays in the middle
|
|
2439
|
+
half of its side.
|
|
2440
|
+
"""
|
|
2441
|
+
out: List[Tuple[Edge, List[Pt]]] = []
|
|
2442
|
+
paths = [pts for _e, pts in routed]
|
|
2443
|
+
for i, (e, pts) in enumerate(routed):
|
|
2444
|
+
if (e.route != "ortho" or getattr(e, "bundle", None) or len(pts) < 3
|
|
2445
|
+
or getattr(e, "waypoints", None)):
|
|
2446
|
+
out.append((e, pts))
|
|
2447
|
+
continue
|
|
2448
|
+
from .routing import _absorb_jogs
|
|
2449
|
+
|
|
2450
|
+
tol = e.jog_tol if e.jog_tol is not None else min(6, 0.35 * self.theme.gap)
|
|
2451
|
+
candidate = _absorb_jogs(pts, tol)
|
|
2452
|
+
p3 = None
|
|
2453
|
+
if (candidate != _simplify_ortho(pts) and candidate
|
|
2454
|
+
and candidate[0] == pts[0] and candidate[-1] == pts[-1]
|
|
2455
|
+
and not any(self._seg_blocked(e, a, b, paths[:i] + paths[i + 1:])
|
|
2456
|
+
for a, b in zip(candidate, candidate[1:]))):
|
|
2457
|
+
p3 = candidate
|
|
2458
|
+
if p3 is None and (candidate != _simplify_ortho(pts) or e.jog_tol is None):
|
|
2459
|
+
p3 = self._straighten_z(e, _simplify_ortho(pts), paths[:i] + paths[i + 1:])
|
|
2460
|
+
p2 = p3 if p3 is not None else _simplify_ortho(pts, backtracks_only=True)
|
|
2461
|
+
paths[i] = p2
|
|
2462
|
+
out.append((e, p2))
|
|
2463
|
+
return out
|
|
2464
|
+
|
|
2465
|
+
def _straighten_z(self, e: "Edge", pts: List[Pt], others: List[List[Pt]]) -> Optional[List[Pt]]:
|
|
2466
|
+
"""One straight segment for a Z (or, v0.10.0, multi-jog) route, if safe.
|
|
2467
|
+
|
|
2468
|
+
The first and last runs must be parallel and head the same way, and the
|
|
2469
|
+
whole route must make monotonic progress along them. A plain side port is
|
|
2470
|
+
slid along its side so the two ends line up: always for a jog ≤ L(8); for a
|
|
2471
|
+
larger one (v0.10.0) only when the slid point stays in the middle half of
|
|
2472
|
+
the side (``0.25 ≤ at ≤ 0.75``), so the arrow still reads as attached to
|
|
2473
|
+
the node's face rather than to a corner. A port with an explicit ``at=`` and
|
|
2474
|
+
``slide=s`` (v0.11.0) may move by up to ``s`` instead.
|
|
2475
|
+
"""
|
|
2476
|
+
if len(pts) < 4:
|
|
2477
|
+
return None
|
|
2478
|
+
th = self.theme
|
|
2479
|
+
(x0, y0), (x1, y1) = pts[0], pts[1]
|
|
2480
|
+
(x2, y2), (x3, y3) = pts[-2], pts[-1]
|
|
2481
|
+
h_run = abs(y0 - y1) < 1e-6 and abs(y2 - y3) < 1e-6
|
|
2482
|
+
v_run = abs(x0 - x1) < 1e-6 and abs(x2 - x3) < 1e-6
|
|
2483
|
+
if not (h_run or v_run):
|
|
2484
|
+
return None
|
|
2485
|
+
k = 1 if h_run else 0 # coordinate that the jog changes
|
|
2486
|
+
jog = pts[-1][k] - pts[0][k]
|
|
2487
|
+
if abs(jog) <= 0.05:
|
|
2488
|
+
return None
|
|
2489
|
+
# both runs must head the same way (a real Z, not a U-turn), and every
|
|
2490
|
+
# run along that axis must too (no detour back)
|
|
2491
|
+
d01 = pts[1][1 - k] - pts[0][1 - k]
|
|
2492
|
+
d23 = pts[-1][1 - k] - pts[-2][1 - k]
|
|
2493
|
+
if d01 * d23 <= 0:
|
|
2494
|
+
return None
|
|
2495
|
+
for u, v in zip(pts, pts[1:]):
|
|
2496
|
+
if (v[1 - k] - u[1 - k]) * d01 < -1e-6:
|
|
2497
|
+
return None
|
|
2498
|
+
if len(pts) > 4:
|
|
2499
|
+
# multi-jog: every cross run must move the same way (a staircase)
|
|
2500
|
+
steps = [v[k] - u[k] for u, v in zip(pts, pts[1:]) if abs(v[k] - u[k]) > 1e-6]
|
|
2501
|
+
if any(st * jog < 0 for st in steps):
|
|
2502
|
+
return None
|
|
2503
|
+
|
|
2504
|
+
def slidable(p: Port) -> bool:
|
|
2505
|
+
return p.kind == "side" and p.side in ("left", "right", "top", "bottom") and \
|
|
2506
|
+
(abs(p.at - 0.5) < 1e-9 or p.slide > 0) and p.offset == 0
|
|
2507
|
+
|
|
2508
|
+
normal_k = 1 - k
|
|
2509
|
+
cands = []
|
|
2510
|
+
for port, end, t in ((e.src, 0, pts[-1][k]), (e.dst, -1, pts[0][k])):
|
|
2511
|
+
if not slidable(port):
|
|
2512
|
+
continue
|
|
2513
|
+
# the port's side must be perpendicular to the run
|
|
2514
|
+
if (port.side in ("left", "right")) != (k == 1):
|
|
2515
|
+
continue
|
|
2516
|
+
lo, hi = Edge._span(port, k)
|
|
2517
|
+
m = th.L(2.0)
|
|
2518
|
+
if not lo + m <= t <= hi - m:
|
|
2519
|
+
continue
|
|
2520
|
+
at = 0.5 if hi - lo < 1e-9 else (t - lo) / (hi - lo)
|
|
2521
|
+
if port.slide > 0: # explicit at= with a slide tolerance (v0.11.0)
|
|
2522
|
+
if abs(at - port.at) > port.slide + 1e-9:
|
|
2523
|
+
continue
|
|
2524
|
+
elif abs(jog) > th.L(8.0) and not 0.25 <= at <= 0.75:
|
|
2525
|
+
continue
|
|
2526
|
+
cands.append((abs(at - port.at), port, end, t))
|
|
2527
|
+
for _d, port, end, t in sorted(cands, key=lambda c: c[0]):
|
|
2528
|
+
q = Edge._slide(port, t, k)
|
|
2529
|
+
if abs(q[k] - t) > 0.05 or abs(q[normal_k] - pts[end][normal_k]) > 0.3:
|
|
2530
|
+
continue
|
|
2531
|
+
new = [(q[0], q[1]), pts[-1]] if end == 0 else [pts[0], (q[0], q[1])]
|
|
2532
|
+
if any(abs(q[0] - p[0]) < 0.8 and abs(q[1] - p[1]) < 0.8
|
|
2533
|
+
for path in others if path for p in (path[0], path[-1])):
|
|
2534
|
+
continue
|
|
2535
|
+
if self._seg_blocked(e, new[0], new[1], others):
|
|
2536
|
+
continue
|
|
2537
|
+
if e.label and self._label_hits(e, new, others) > self._label_hits(e, pts, others) + 0.5:
|
|
2538
|
+
continue # the straight segment leaves the label no room
|
|
2539
|
+
return new
|
|
2540
|
+
return None
|
|
2541
|
+
|
|
2542
|
+
def _label_hits(self, e: "Edge", pts: List[Pt], others: List[List[Pt]]) -> float:
|
|
2543
|
+
"""Area of ``e``'s label (placed on ``pts``) over leaf boxes of the diagram."""
|
|
2544
|
+
from .policy import role_of
|
|
2545
|
+
|
|
2546
|
+
leaves: List[Tuple[float, float, float, float]] = []
|
|
2547
|
+
|
|
2548
|
+
def rec(n: Node) -> None:
|
|
2549
|
+
kids = list(n.children())
|
|
2550
|
+
if not kids and role_of(n) in ("hard", "soft"):
|
|
2551
|
+
g = n.geom
|
|
2552
|
+
if g.w > 0.5 and g.h > 0.5:
|
|
2553
|
+
leaves.append((g.x, g.y, g.w, g.h))
|
|
2554
|
+
for k in kids:
|
|
2555
|
+
rec(k)
|
|
2556
|
+
|
|
2557
|
+
rec(self.content)
|
|
2558
|
+
try:
|
|
2559
|
+
box = e.label_geometry(pts, self.theme, list(leaves), paths=others)[0]
|
|
2560
|
+
except Exception: # pragma: no cover
|
|
2561
|
+
return 0.0
|
|
2562
|
+
bx, by, bw, bh = box
|
|
2563
|
+
tot = 0.0
|
|
2564
|
+
for rx, ry, rw, rh in leaves:
|
|
2565
|
+
w = min(bx + bw, rx + rw) - max(bx, rx)
|
|
2566
|
+
h = min(by + bh, ry + rh) - max(by, ry)
|
|
2567
|
+
if w > 0 and h > 0:
|
|
2568
|
+
tot += w * h
|
|
2569
|
+
return tot
|
|
2570
|
+
|
|
2571
|
+
def _seg_blocked(self, e: "Edge", a: Pt, b: Pt, others: List[List[Pt]]) -> bool:
|
|
2572
|
+
x0, x1 = sorted((a[0], b[0]))
|
|
2573
|
+
y0, y1 = sorted((a[1], b[1]))
|
|
2574
|
+
ins = 0.5
|
|
2575
|
+
for o in self._obstacles_for(e):
|
|
2576
|
+
if getattr(o, "role", "hard") != "hard":
|
|
2577
|
+
continue
|
|
2578
|
+
rx, ry, rw, rh = o[0], o[1], o[2], o[3]
|
|
2579
|
+
if x0 < rx + rw - ins and x1 > rx + ins and y0 < ry + rh - ins and y1 > ry + ins:
|
|
2580
|
+
return True
|
|
2581
|
+
tol = 0.8
|
|
2582
|
+
for pts in others:
|
|
2583
|
+
for (u, v) in zip(pts, pts[1:]):
|
|
2584
|
+
if abs(a[1] - b[1]) < 1e-6 and abs(u[1] - v[1]) < 1e-6 and abs(u[1] - a[1]) < tol:
|
|
2585
|
+
if min(max(u[0], v[0]), x1) - max(min(u[0], v[0]), x0) > tol:
|
|
2586
|
+
return True
|
|
2587
|
+
if abs(a[0] - b[0]) < 1e-6 and abs(u[0] - v[0]) < 1e-6 and abs(u[0] - a[0]) < tol:
|
|
2588
|
+
if min(max(u[1], v[1]), y1) - max(min(u[1], v[1]), y0) > tol:
|
|
2589
|
+
return True
|
|
2590
|
+
return False
|
|
2591
|
+
|
|
2592
|
+
# ---- forks ------------------------------------------------------------------
|
|
2593
|
+
def _plain_edges(self) -> List[Edge]:
|
|
2594
|
+
return [e for e in self.edges if isinstance(e, Edge)]
|
|
2595
|
+
|
|
2596
|
+
def _forks(self) -> List[Fork]:
|
|
2597
|
+
return [e for e in self.edges if isinstance(e, Fork)]
|
|
2598
|
+
|
|
2599
|
+
def _fork_obstacles(self, fork: Fork):
|
|
2600
|
+
from .routing import ancestors_in, collect_obstacles
|
|
2601
|
+
|
|
2602
|
+
nodes = [fork._node(fork.hub)] + [fork._node(l) for l in fork.leaves]
|
|
2603
|
+
transparent = ancestors_in(self.content, nodes)
|
|
2604
|
+
return collect_obstacles(self.content, exclude=nodes, pad=5.0, transparent=transparent)
|
|
2605
|
+
|
|
2606
|
+
# ---- layering: knockout holes + label layout --------------------------------
|
|
2607
|
+
def _soft_text_boxes(self) -> List[Tuple[Any, Tuple[float, float, float, float]]]:
|
|
2608
|
+
"""(node, ink box) of every soft, knockout-enabled text in content + overlays."""
|
|
2609
|
+
from .policy import knockout_of, role_of
|
|
2610
|
+
|
|
2611
|
+
out: List[Tuple[Any, Tuple[float, float, float, float]]] = []
|
|
2612
|
+
|
|
2613
|
+
def add(n: Node) -> None:
|
|
2614
|
+
if role_of(n) == "soft" and knockout_of(n):
|
|
2615
|
+
g = n.geom
|
|
2616
|
+
if g.w > 0.5 and g.h > 0.5:
|
|
2617
|
+
box = n.ink_box() if hasattr(n, "ink_box") else (g.x, g.y, g.w, g.h)
|
|
2618
|
+
out.append((n, box))
|
|
2619
|
+
|
|
2620
|
+
def walk(n: Node) -> None:
|
|
2621
|
+
add(n)
|
|
2622
|
+
for k in n.children():
|
|
2623
|
+
walk(k)
|
|
2624
|
+
|
|
2625
|
+
walk(self.content)
|
|
2626
|
+
for o in getattr(self, "_cf_overlays_all", None) or []:
|
|
2627
|
+
walk(o)
|
|
2628
|
+
return out
|
|
2629
|
+
|
|
2630
|
+
def layout_edges(self) -> Dict[str, Any]:
|
|
2631
|
+
"""Route every edge and place every label (no drawing). Cached per draw."""
|
|
2632
|
+
for e in self._plain_edges():
|
|
2633
|
+
if getattr(e, "_par_group", None):
|
|
2634
|
+
e._par_assign_label()
|
|
2635
|
+
routed, buses = self._routed()
|
|
2636
|
+
obs = self._label_obstacles()
|
|
2637
|
+
paths = [pts for _e, pts in routed]
|
|
2638
|
+
steps: List[Tuple[Edge, Tuple[float, float, float, float]]] = []
|
|
2639
|
+
for edge, pts in routed:
|
|
2640
|
+
if getattr(edge, "step", None) is None:
|
|
2641
|
+
continue
|
|
2642
|
+
sg = edge.step_geometry(pts, obs)
|
|
2643
|
+
if sg is not None:
|
|
2644
|
+
x, y, d = sg
|
|
2645
|
+
box = (x - d / 2, y - d / 2, d, d)
|
|
2646
|
+
steps.append((edge, box))
|
|
2647
|
+
obs.append(box)
|
|
2648
|
+
labels: List[Tuple[Edge, List[Pt], Any]] = []
|
|
2649
|
+
for i, (edge, pts) in enumerate(routed):
|
|
2650
|
+
if edge.label:
|
|
2651
|
+
others = paths[:i] + paths[i + 1:] + list(getattr(self, "_label_frames", []))
|
|
2652
|
+
geo = edge.label_geometry(pts, self.theme, obs, paths=others)
|
|
2653
|
+
labels.append((edge, pts, geo))
|
|
2654
|
+
obs.append(geo[0])
|
|
2655
|
+
from .routing import ORect, route_ortho_avoid
|
|
2656
|
+
for i, (edge, pts) in enumerate(routed):
|
|
2657
|
+
if getattr(edge, "_cf_guide_edge", False) and edge.avoid_text:
|
|
2658
|
+
boxes = [geo[0] for owner, _, geo in labels if owner is not edge]
|
|
2659
|
+
if any(_poly_hits_rect(pts, box) for box in boxes):
|
|
2660
|
+
obstacles = [ORect(*o) for o in self._obstacles_for(edge)
|
|
2661
|
+
if getattr(o, "role", "hard") != "background"]
|
|
2662
|
+
obstacles += [ORect(*box) for box in boxes + self._title_boxes()]
|
|
2663
|
+
a, b = edge.endpoints()
|
|
2664
|
+
routed[i] = (edge, route_ortho_avoid(*a, *b, stub=edge.stub, obstacles=obstacles))
|
|
2665
|
+
return {"routed": routed, "buses": buses, "labels": labels, "steps": steps}
|
|
2666
|
+
|
|
2667
|
+
def _holes_for(self, edge: Edge, pts: List[Pt], soft: Sequence[Any],
|
|
2668
|
+
label_boxes: Sequence[Tuple[Edge, Tuple[float, float, float, float]]]
|
|
2669
|
+
) -> List[Tuple[float, float, float, float]]:
|
|
2670
|
+
if not edge.knockout_enabled(self.theme):
|
|
2671
|
+
return []
|
|
2672
|
+
from .policy import knockout_enabled # noqa: F401 (figure-level default)
|
|
2673
|
+
|
|
2674
|
+
pad = float(getattr(self.theme, "knockout_pad", 1.5))
|
|
2675
|
+
ends = {id(edge.src.node), id(edge.dst.node)}
|
|
2676
|
+
holes = []
|
|
2677
|
+
for n, (x, y, w, h) in soft:
|
|
2678
|
+
if id(n) in ends:
|
|
2679
|
+
continue
|
|
2680
|
+
r = (x - pad, y - pad, w + 2 * pad, h + 2 * pad)
|
|
2681
|
+
if _poly_hits_rect(pts, r):
|
|
2682
|
+
holes.append(r)
|
|
2683
|
+
for owner, (x, y, w, h) in label_boxes:
|
|
2684
|
+
if owner is edge and edge.label_side != "on":
|
|
2685
|
+
continue
|
|
2686
|
+
r = (x - pad, y - pad, w + 2 * pad, h + 2 * pad)
|
|
2687
|
+
if _poly_hits_rect(pts, r):
|
|
2688
|
+
holes.append(r)
|
|
2689
|
+
# v0.12: an edge leaving a titled Group through its title is cut around the
|
|
2690
|
+
# title text instead of striking it through
|
|
2691
|
+
for (x, y, w, h) in self._title_boxes():
|
|
2692
|
+
r = (x - pad, y - pad, w + 2 * pad, h + 2 * pad)
|
|
2693
|
+
if _poly_hits_rect(pts, r):
|
|
2694
|
+
holes.append(r)
|
|
2695
|
+
return holes
|
|
2696
|
+
|
|
2697
|
+
def _title_boxes(self) -> List[Tuple[float, float, float, float]]:
|
|
2698
|
+
from .constraints import walk
|
|
2699
|
+
|
|
2700
|
+
out: List[Tuple[float, float, float, float]] = []
|
|
2701
|
+
trees = [self.content] + list(getattr(self, "_cf_overlays_all", None) or [])
|
|
2702
|
+
for t in trees:
|
|
2703
|
+
for n in walk(t):
|
|
2704
|
+
tb = getattr(n, "title_boxes", None)
|
|
2705
|
+
if tb is not None and getattr(n, "title", None):
|
|
2706
|
+
try:
|
|
2707
|
+
out.extend(tb())
|
|
2708
|
+
except Exception: # pragma: no cover
|
|
2709
|
+
pass
|
|
2710
|
+
return out
|
|
2711
|
+
|
|
2712
|
+
def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
|
|
2713
|
+
from .render_svg import draw_circle, draw_line
|
|
2714
|
+
|
|
2715
|
+
if self.regions: # behind everything; labels after the content
|
|
2716
|
+
self._layout_regions()
|
|
2717
|
+
for i, r in enumerate(self.regions):
|
|
2718
|
+
r.draw(buf, ox, oy, index=i)
|
|
2719
|
+
for i, r in enumerate(self.regions):
|
|
2720
|
+
r.draw_label(buf, ox, oy, index=i)
|
|
2721
|
+
self.content.draw(buf, ox, oy)
|
|
2722
|
+
lay = self.layout_edges()
|
|
2723
|
+
routed, buses = lay["routed"], lay["buses"]
|
|
2724
|
+
soft = self._soft_text_boxes()
|
|
2725
|
+
label_boxes = [(e, geo[0]) for e, _p, geo in lay["labels"]]
|
|
2726
|
+
label_boxes += [(e, box) for e, box in lay.get("steps", [])]
|
|
2727
|
+
# Bus underlays first (behind individual edges)
|
|
2728
|
+
for bus in buses:
|
|
2729
|
+
if bus["kind"] == "v":
|
|
2730
|
+
draw_line(
|
|
2731
|
+
buf,
|
|
2732
|
+
bus["x"] + ox, bus["y0"] + oy,
|
|
2733
|
+
bus["x"] + ox, bus["y1"] + oy,
|
|
2734
|
+
stroke=bus["color"],
|
|
2735
|
+
stroke_width=bus["width"],
|
|
2736
|
+
opacity=bus["opacity"],
|
|
2737
|
+
)
|
|
2738
|
+
else:
|
|
2739
|
+
draw_line(
|
|
2740
|
+
buf,
|
|
2741
|
+
bus["x0"] + ox, bus["y"] + oy,
|
|
2742
|
+
bus["x1"] + ox, bus["y"] + oy,
|
|
2743
|
+
stroke=bus["color"],
|
|
2744
|
+
stroke_width=bus["width"],
|
|
2745
|
+
opacity=bus["opacity"],
|
|
2746
|
+
)
|
|
2747
|
+
curves = getattr(self, "_curves", {})
|
|
2748
|
+
for edge, pts in routed:
|
|
2749
|
+
holes = self._holes_for(edge, pts, soft, label_boxes)
|
|
2750
|
+
holes = [(x + ox, y + oy, w, h) for x, y, w, h in holes]
|
|
2751
|
+
edge.draw(buf, ox, oy, theme=self.theme, points=pts, defer_label=True,
|
|
2752
|
+
curve=curves.get(id(edge)), holes=holes)
|
|
2753
|
+
for fork in self._forks():
|
|
2754
|
+
if fork._dot_at is not None and fork._edges:
|
|
2755
|
+
st = fork._edges[0].resolved(self.theme)
|
|
2756
|
+
r = fork.dot_size if fork.dot_size is not None else max(2.2, st.width * 1.8)
|
|
2757
|
+
draw_circle(buf, fork._dot_at[0] + ox, fork._dot_at[1] + oy, r, fill=st.color,
|
|
2758
|
+
stroke="none", stroke_width=0)
|
|
2759
|
+
# Step badges (①) sit on their own path, above every edge line
|
|
2760
|
+
for edge, (x, y, w, h) in lay.get("steps", []):
|
|
2761
|
+
edge.step.paint(buf, x + w / 2 + ox, y + h / 2 + oy, w)
|
|
2762
|
+
# Labels go on top of every edge path; edges passing under a label are
|
|
2763
|
+
# knocked out around it (soft-text rule), so no halo colour is needed.
|
|
2764
|
+
for edge, pts, geo in lay["labels"]:
|
|
2765
|
+
p2 = [(x + ox, y + oy) for x, y in pts]
|
|
2766
|
+
(x, y, w, h), r_, a_ = geo
|
|
2767
|
+
edge._draw_label(buf, p2, self.theme, edge.resolved(self.theme), [],
|
|
2768
|
+
geometry=((x + ox, y + oy, w, h), r_, a_))
|
|
2769
|
+
self._placed_labels = [geo[0] for _e, _p, geo in lay["labels"]]
|
|
2770
|
+
|
|
2771
|
+
def _label_obstacles(self) -> List[Tuple[float, float, float, float]]:
|
|
2772
|
+
from .policy import role_of
|
|
2773
|
+
|
|
2774
|
+
out: List[Tuple[float, float, float, float]] = []
|
|
2775
|
+
frames: List[List[Pt]] = []
|
|
2776
|
+
|
|
2777
|
+
def rect_poly(x: float, y: float, w: float, h: float) -> List[Pt]:
|
|
2778
|
+
return [(x, y), (x + w, y), (x + w, y + h), (x, y + h), (x, y)]
|
|
2779
|
+
|
|
2780
|
+
def walk(n: Node) -> None:
|
|
2781
|
+
role = role_of(n)
|
|
2782
|
+
g0 = getattr(n, "geom", None)
|
|
2783
|
+
if g0 is not None and g0.w > 0.5 and g0.h > 0.5 and role not in ("hard", "soft"):
|
|
2784
|
+
# v0.12: visible frames of background containers (Group, Stacked
|
|
2785
|
+
# decks) steer labels too — a label must not sit on an outline
|
|
2786
|
+
st = getattr(n, "stroke", None)
|
|
2787
|
+
if getattr(n, "border", None) != "none" and st and st != "none":
|
|
2788
|
+
frames.append(rect_poly(g0.x, g0.y, g0.w, g0.h))
|
|
2789
|
+
elif type(n).__name__ == "Divider":
|
|
2790
|
+
if getattr(n, "orientation", "vertical") == "vertical":
|
|
2791
|
+
cx_ = g0.x + g0.w / 2
|
|
2792
|
+
frames.append([(cx_, g0.y), (cx_, g0.y + g0.h)])
|
|
2793
|
+
else:
|
|
2794
|
+
cy_ = g0.y + g0.h / 2
|
|
2795
|
+
frames.append([(g0.x, cy_), (g0.x + g0.w, cy_)])
|
|
2796
|
+
elif type(n).__name__ == "Stacked":
|
|
2797
|
+
frames.append(rect_poly(g0.x, g0.y, g0.w, g0.h))
|
|
2798
|
+
fg = n.node.geom
|
|
2799
|
+
for c in range(1, n.n):
|
|
2800
|
+
frames.append(rect_poly(fg.x + n.dx * c, fg.y + n.dy * c, fg.w, fg.h))
|
|
2801
|
+
if role in ("hard", "soft"):
|
|
2802
|
+
g = n.geom
|
|
2803
|
+
if g.w > 0.5 and g.h > 0.5:
|
|
2804
|
+
if role == "soft" and hasattr(n, "ink_box"):
|
|
2805
|
+
x, y, w, h = n.ink_box()
|
|
2806
|
+
out.append((x - 1, y - 1, w + 2, h + 2))
|
|
2807
|
+
else:
|
|
2808
|
+
out.append((g.x - 1, g.y - 1, g.w + 2, g.h + 2))
|
|
2809
|
+
for k in n.children():
|
|
2810
|
+
walk(k)
|
|
2811
|
+
|
|
2812
|
+
walk(self.content)
|
|
2813
|
+
for o in getattr(self, "_cf_overlays_all", None) or []:
|
|
2814
|
+
walk(o)
|
|
2815
|
+
self._n_label_obs = len(out)
|
|
2816
|
+
self._label_frames = frames
|
|
2817
|
+
return out
|
|
2818
|
+
|
|
2819
|
+
def label_boxes(self) -> List[Tuple[float, float, float, float]]:
|
|
2820
|
+
"""Bounding boxes of every edge label (page coordinates) after layout."""
|
|
2821
|
+
if getattr(self, "_placed_labels", None) is None:
|
|
2822
|
+
lay = self.layout_edges()
|
|
2823
|
+
self._placed_labels = [geo[0] for _e, _p, geo in lay["labels"]]
|
|
2824
|
+
return list(self._placed_labels)
|