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/shapes.py
ADDED
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
"""Outline shapes with the full Box API (v0.4.0).
|
|
2
|
+
|
|
3
|
+
Every shape is a :class:`~codefig.primitives.Box` subclass — text / rich markup /
|
|
4
|
+
subtitle, ``fill=`` (colour, Gradient, Split), ``stroke``, ``border=``, ``pattern=``
|
|
5
|
+
(hatch, clipped to the outline), ``icon=`` / ``badge=``, ``legend=`` — that sizes itself
|
|
6
|
+
so the label fits *inside* the outline, and whose ports sit on the true outline
|
|
7
|
+
(``shape.port("top", at=0.2)`` lands on the curve, not on the bounding box;
|
|
8
|
+
``port("auto")`` aims at the other end of the edge). Role: hard (as Box).
|
|
9
|
+
|
|
10
|
+
Ellipse · Circle · OpCircle (⊕ ⊗ ⊙ …) · Op (bare ×, +) · Cylinder · Document · Trapezoid ·
|
|
11
|
+
Triangle · Polygon · Parallelogram · Hexagon · Diamond · ChatBubble · BlockArrow
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import math
|
|
17
|
+
from typing import Any, List, Optional, Sequence, Tuple
|
|
18
|
+
|
|
19
|
+
from .primitives import Box, _paint_node_pattern
|
|
20
|
+
|
|
21
|
+
Pt = Tuple[float, float]
|
|
22
|
+
SQ2 = math.sqrt(2.0)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _f(v: float) -> str:
|
|
26
|
+
return f"{v:.2f}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _poly_d(pts: Sequence[Pt]) -> str:
|
|
30
|
+
return "M " + " L ".join(f"{_f(x)},{_f(y)}" for x, y in pts) + " Z"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _ellipse_pts(cx: float, cy: float, rx: float, ry: float, a0: float = 0.0, a1: float = 2 * math.pi,
|
|
34
|
+
n: int = 48) -> List[Pt]:
|
|
35
|
+
return [(cx + rx * math.cos(a0 + (a1 - a0) * i / n), cy + ry * math.sin(a0 + (a1 - a0) * i / n))
|
|
36
|
+
for i in range(n + 1)]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Shape(Box):
|
|
40
|
+
"""Base class: subclasses define ``_frame_size`` / ``_text_rect`` / ``_outline``."""
|
|
41
|
+
|
|
42
|
+
_seq = 0
|
|
43
|
+
|
|
44
|
+
# -- geometry hooks ---------------------------------------------------------------
|
|
45
|
+
def _outline(self, x: float, y: float, w: float, h: float) -> List[Pt]:
|
|
46
|
+
"""Closed outline polygon (sampled for curves), page coordinates."""
|
|
47
|
+
return [(x, y), (x + w, y), (x + w, y + h), (x, y + h)]
|
|
48
|
+
|
|
49
|
+
def _path(self, x: float, y: float, w: float, h: float) -> str:
|
|
50
|
+
"""SVG path ``d`` of the outline (curves exact where possible)."""
|
|
51
|
+
return _poly_d(self._outline(x, y, w, h))
|
|
52
|
+
|
|
53
|
+
def _decorate(self, buf: List[str], x: float, y: float, w: float, h: float) -> None:
|
|
54
|
+
"""Extra strokes drawn over the fill (cylinder rim, operator glyph, …)."""
|
|
55
|
+
|
|
56
|
+
# -- outline queries (used by ports / overlap checks) ------------------------------
|
|
57
|
+
def outline_polygon(self) -> List[Pt]:
|
|
58
|
+
g = self.geom
|
|
59
|
+
return self._outline(g.x, g.y, g.w, g.h)
|
|
60
|
+
|
|
61
|
+
def outline_point(self, side: str, x: float, y: float) -> Pt:
|
|
62
|
+
"""Where a ray entering from ``side`` along the port's axis first meets the outline."""
|
|
63
|
+
poly = self.outline_polygon()
|
|
64
|
+
xs = [p[0] for p in poly]
|
|
65
|
+
ys = [p[1] for p in poly]
|
|
66
|
+
if side in ("top", "bottom"):
|
|
67
|
+
v = min(max(x, min(xs) + 0.5), max(xs) - 0.5)
|
|
68
|
+
hits = _hits(poly, v, vertical=True)
|
|
69
|
+
if not hits:
|
|
70
|
+
return x, y
|
|
71
|
+
return v, (min(hits) if side == "top" else max(hits))
|
|
72
|
+
if side in ("left", "right"):
|
|
73
|
+
v = min(max(y, min(ys) + 0.5), max(ys) - 0.5)
|
|
74
|
+
hits = _hits(poly, v, vertical=False)
|
|
75
|
+
if not hits:
|
|
76
|
+
return x, y
|
|
77
|
+
return (min(hits) if side == "left" else max(hits)), v
|
|
78
|
+
return x, y
|
|
79
|
+
|
|
80
|
+
def ray_point(self, dx: float, dy: float) -> Pt:
|
|
81
|
+
"""Outline point in direction (dx, dy) from the centre of the label area."""
|
|
82
|
+
g = self.geom
|
|
83
|
+
tx, ty, tw, th = self._text_rect(g.x, g.y, g.w, g.h)
|
|
84
|
+
return ray_hit(self.outline_polygon(), tx + tw / 2, ty + th / 2, dx, dy)
|
|
85
|
+
|
|
86
|
+
# -- drawing ------------------------------------------------------------------------
|
|
87
|
+
def _draw_frame(self, buf: List[str], x: float, y: float, w: float, h: float) -> None:
|
|
88
|
+
from .render_svg import draw_path
|
|
89
|
+
|
|
90
|
+
d = self._path(x, y, w, h)
|
|
91
|
+
stroke = self._border_stroke(self.stroke)
|
|
92
|
+
draw_path(buf, d, fill=self.fill, stroke="none", stroke_width=0)
|
|
93
|
+
if self.pattern is not None:
|
|
94
|
+
Shape._seq += 1
|
|
95
|
+
cid = f"cf-shape-{Shape._seq}"
|
|
96
|
+
buf.append(f'<defs><clipPath id="{cid}"><path d="{d}"/></clipPath></defs>'
|
|
97
|
+
f'<g clip-path="url(#{cid})">')
|
|
98
|
+
_paint_node_pattern(buf, self.pattern, x, y, w, h)
|
|
99
|
+
buf.append("</g>")
|
|
100
|
+
if stroke not in (None, "none") and self.stroke_width > 0:
|
|
101
|
+
draw_path(buf, d, fill="none", stroke=stroke, stroke_width=self.stroke_width,
|
|
102
|
+
dasharray=self._dash(self.stroke_width))
|
|
103
|
+
self._decorate(buf, x, y, w, h)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _hits(poly: Sequence[Pt], v: float, *, vertical: bool) -> List[float]:
|
|
107
|
+
out = []
|
|
108
|
+
n = len(poly)
|
|
109
|
+
for i in range(n):
|
|
110
|
+
(x1, y1), (x2, y2) = poly[i], poly[(i + 1) % n]
|
|
111
|
+
a1, a2, b1, b2 = (x1, x2, y1, y2) if vertical else (y1, y2, x1, x2)
|
|
112
|
+
if (a1 - v) * (a2 - v) > 0 or a1 == a2:
|
|
113
|
+
if a1 == a2 == v:
|
|
114
|
+
out += [b1, b2]
|
|
115
|
+
continue
|
|
116
|
+
t = (v - a1) / (a2 - a1)
|
|
117
|
+
out.append(b1 + t * (b2 - b1))
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def ray_hit(poly: Sequence[Pt], cx: float, cy: float, dx: float, dy: float) -> Pt:
|
|
122
|
+
"""First intersection of the ray (cx, cy) + t·(dx, dy), t > 0, with a closed polygon."""
|
|
123
|
+
best = None
|
|
124
|
+
n = len(poly)
|
|
125
|
+
for i in range(n):
|
|
126
|
+
(x1, y1), (x2, y2) = poly[i], poly[(i + 1) % n]
|
|
127
|
+
ex, ey = x2 - x1, y2 - y1
|
|
128
|
+
den = dx * ey - dy * ex
|
|
129
|
+
if abs(den) < 1e-12:
|
|
130
|
+
continue
|
|
131
|
+
t = ((x1 - cx) * ey - (y1 - cy) * ex) / den
|
|
132
|
+
u = ((x1 - cx) * dy - (y1 - cy) * dx) / den
|
|
133
|
+
if t > 1e-9 and -1e-9 <= u <= 1 + 1e-9 and (best is None or t > best):
|
|
134
|
+
best = t # farthest crossing = the outer boundary for star-shaped outlines
|
|
135
|
+
if best is None:
|
|
136
|
+
return cx, cy
|
|
137
|
+
return cx + dx * best, cy + dy * best
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------------------- ellipse family
|
|
141
|
+
class Ellipse(Shape):
|
|
142
|
+
"""Ellipse sized so the label rectangle is inscribed (w, h = √2 × label box)."""
|
|
143
|
+
|
|
144
|
+
def _frame_size(self, iw: float, ih: float) -> Tuple[float, float]:
|
|
145
|
+
return iw * SQ2, ih * SQ2
|
|
146
|
+
|
|
147
|
+
def _text_rect(self, x, y, w, h):
|
|
148
|
+
iw, ih = w / SQ2, h / SQ2
|
|
149
|
+
return x + (w - iw) / 2, y + (h - ih) / 2, iw, ih
|
|
150
|
+
|
|
151
|
+
def _outline(self, x, y, w, h):
|
|
152
|
+
return _ellipse_pts(x + w / 2, y + h / 2, w / 2, h / 2, n=72)[:-1]
|
|
153
|
+
|
|
154
|
+
def _path(self, x, y, w, h):
|
|
155
|
+
rx, ry, cy = w / 2, h / 2, y + h / 2
|
|
156
|
+
return (f"M {_f(x)},{_f(cy)} A {_f(rx)},{_f(ry)} 0 1 1 {_f(x + w)},{_f(cy)} "
|
|
157
|
+
f"A {_f(rx)},{_f(ry)} 0 1 1 {_f(x)},{_f(cy)} Z")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class Circle(Ellipse):
|
|
161
|
+
"""Circle around the label; ``size=`` fixes the diameter.
|
|
162
|
+
|
|
163
|
+
Without ``size=`` the diameter follows the label: about the label's width plus the
|
|
164
|
+
theme padding, so a larger ``font_size`` (or a longer label) makes a larger circle.
|
|
165
|
+
Pass ``size=`` to keep several circles equal."""
|
|
166
|
+
|
|
167
|
+
def __init__(self, text: str = "", *, size: Optional[float] = None, **kwargs: Any) -> None:
|
|
168
|
+
if size is not None:
|
|
169
|
+
kwargs.setdefault("w", float(size))
|
|
170
|
+
kwargs.setdefault("h", float(size))
|
|
171
|
+
super().__init__(text, **kwargs)
|
|
172
|
+
|
|
173
|
+
def _frame_size(self, iw, ih):
|
|
174
|
+
d = math.hypot(iw, ih)
|
|
175
|
+
return d, d
|
|
176
|
+
|
|
177
|
+
def _measure(self):
|
|
178
|
+
w, h = super()._measure()
|
|
179
|
+
d = max(w, h) if (self.fixed_w is None or self.fixed_h is None) else min(w, h)
|
|
180
|
+
return d, d
|
|
181
|
+
|
|
182
|
+
def _text_rect(self, x, y, w, h):
|
|
183
|
+
d = min(w, h)
|
|
184
|
+
s = d / SQ2
|
|
185
|
+
return x + (w - s) / 2, y + (h - s) / 2, s, s
|
|
186
|
+
|
|
187
|
+
def _outline(self, x, y, w, h):
|
|
188
|
+
r = min(w, h) / 2
|
|
189
|
+
return _ellipse_pts(x + w / 2, y + h / 2, r, r, n=72)[:-1]
|
|
190
|
+
|
|
191
|
+
def _path(self, x, y, w, h):
|
|
192
|
+
r = min(w, h) / 2
|
|
193
|
+
cx, cy = x + w / 2, y + h / 2
|
|
194
|
+
return (f"M {_f(cx - r)},{_f(cy)} A {_f(r)},{_f(r)} 0 1 1 {_f(cx + r)},{_f(cy)} "
|
|
195
|
+
f"A {_f(r)},{_f(r)} 0 1 1 {_f(cx - r)},{_f(cy)} Z")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
_OPS = {"+": "plus", "plus": "plus", "add": "plus", "⊕": "plus",
|
|
199
|
+
"x": "times", "×": "times", "*": "times", "times": "times", "mul": "times", "⊗": "times",
|
|
200
|
+
"dot": "dot", "·": "dot", "⊙": "dot", "hadamard": "dot",
|
|
201
|
+
"-": "minus", "minus": "minus", "⊖": "minus", "c": "concat", "concat": "concat",
|
|
202
|
+
"||": "concat"}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class OpCircle(Circle):
|
|
206
|
+
"""Small operator node: ``op`` = + (⊕) · × (⊗) · dot (⊙) · − · concat (‖), drawn
|
|
207
|
+
geometrically; any other string (σ, Σ, ∫, "C") is set as text. Default diameter
|
|
208
|
+
≈ 1.6 × font size."""
|
|
209
|
+
|
|
210
|
+
def __init__(self, op: str = "+", *, size: Optional[float] = None, color: Optional[str] = None,
|
|
211
|
+
**kwargs: Any) -> None:
|
|
212
|
+
self.op = op
|
|
213
|
+
self.op_kind = _OPS.get(op)
|
|
214
|
+
self.op_color = color
|
|
215
|
+
kwargs.setdefault("pad", 0)
|
|
216
|
+
kwargs.setdefault("text_margin", 0)
|
|
217
|
+
super().__init__("" if self.op_kind else op, size=size, **kwargs)
|
|
218
|
+
if size is None and self.fixed_w is None:
|
|
219
|
+
d = round(self._fs() * 1.6, 1)
|
|
220
|
+
if not self.op_kind:
|
|
221
|
+
d = max(d, super()._measure()[0])
|
|
222
|
+
self.fixed_w = self.fixed_h = d
|
|
223
|
+
self.size_w = self.size_h = d
|
|
224
|
+
|
|
225
|
+
def _decorate(self, buf, x, y, w, h):
|
|
226
|
+
if not self.op_kind:
|
|
227
|
+
return
|
|
228
|
+
from .render_svg import draw_circle, draw_path
|
|
229
|
+
|
|
230
|
+
r = min(w, h) / 2
|
|
231
|
+
cx, cy = x + w / 2, y + h / 2
|
|
232
|
+
col = self.op_color or self._border_stroke(self.stroke) or self.text_color
|
|
233
|
+
if col == "none":
|
|
234
|
+
col = self.text_color
|
|
235
|
+
sw = max(self.theme.SW(1.0), self.stroke_width)
|
|
236
|
+
a = r * 0.55
|
|
237
|
+
if self.op_kind == "plus":
|
|
238
|
+
d = f"M {_f(cx - a)},{_f(cy)} L {_f(cx + a)},{_f(cy)} M {_f(cx)},{_f(cy - a)} L {_f(cx)},{_f(cy + a)}"
|
|
239
|
+
elif self.op_kind == "times":
|
|
240
|
+
b = a * 0.72
|
|
241
|
+
d = (f"M {_f(cx - b)},{_f(cy - b)} L {_f(cx + b)},{_f(cy + b)} "
|
|
242
|
+
f"M {_f(cx + b)},{_f(cy - b)} L {_f(cx - b)},{_f(cy + b)}")
|
|
243
|
+
elif self.op_kind == "minus":
|
|
244
|
+
d = f"M {_f(cx - a)},{_f(cy)} L {_f(cx + a)},{_f(cy)}"
|
|
245
|
+
elif self.op_kind == "concat":
|
|
246
|
+
q = a * 0.35
|
|
247
|
+
d = (f"M {_f(cx - q)},{_f(cy - a)} L {_f(cx - q)},{_f(cy + a)} "
|
|
248
|
+
f"M {_f(cx + q)},{_f(cy - a)} L {_f(cx + q)},{_f(cy + a)}")
|
|
249
|
+
else: # dot
|
|
250
|
+
draw_circle(buf, cx, cy, max(1.4, r * 0.22), fill=col)
|
|
251
|
+
return
|
|
252
|
+
draw_path(buf, d, stroke=col, stroke_width=sw)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class Op(OpCircle):
|
|
256
|
+
"""v0.12.0: a bare operator glyph — ``Op("×")`` / ``Op("+")`` / ``Op("·")`` — drawn
|
|
257
|
+
without the circle, about as tall as a panel title (``size=`` overrides). Use it
|
|
258
|
+
between blocks (``Row([a, Op("×"), b])``); ports sit on its (invisible) circle."""
|
|
259
|
+
|
|
260
|
+
def __init__(self, op: str = "×", *, size: Optional[float] = None, **kwargs: Any) -> None:
|
|
261
|
+
kwargs.setdefault("stroke", "none")
|
|
262
|
+
kwargs.setdefault("fill", "none")
|
|
263
|
+
super().__init__(op, size=size, **kwargs)
|
|
264
|
+
if size is None:
|
|
265
|
+
d = round(max(self.theme.panel_title_size, self._fs()) * 1.25, 1)
|
|
266
|
+
if not self.op_kind:
|
|
267
|
+
d = max(d, Circle._measure(self)[0])
|
|
268
|
+
self.fixed_w = self.fixed_h = d
|
|
269
|
+
self.size_w = self.size_h = d
|
|
270
|
+
|
|
271
|
+
def _decorate(self, buf, x, y, w, h):
|
|
272
|
+
if not self.op_kind:
|
|
273
|
+
return
|
|
274
|
+
sw0 = self.stroke_width
|
|
275
|
+
self.stroke_width = max(self.theme.SW(1.4), sw0)
|
|
276
|
+
try:
|
|
277
|
+
super()._decorate(buf, x, y, w, h)
|
|
278
|
+
finally:
|
|
279
|
+
self.stroke_width = sw0
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# ---------------------------------------------------------------------------- cylinder / doc
|
|
283
|
+
class Cylinder(Shape):
|
|
284
|
+
"""Database / storage drum. ``cap``: ellipse half-height (default from width)."""
|
|
285
|
+
|
|
286
|
+
def __init__(self, text: str = "", *, cap: Optional[float] = None, **kwargs: Any) -> None:
|
|
287
|
+
self.cap = cap
|
|
288
|
+
super().__init__(text, **kwargs)
|
|
289
|
+
|
|
290
|
+
def _ry(self, w: float) -> float:
|
|
291
|
+
return self.cap if self.cap is not None else max(4.0, min(12.0, w * 0.1))
|
|
292
|
+
|
|
293
|
+
def _frame_size(self, iw, ih):
|
|
294
|
+
return iw, ih + 3 * self._ry(iw)
|
|
295
|
+
|
|
296
|
+
def _text_rect(self, x, y, w, h):
|
|
297
|
+
ry = self._ry(w)
|
|
298
|
+
return x, y + 2 * ry, w, h - 3 * ry
|
|
299
|
+
|
|
300
|
+
def _outline(self, x, y, w, h):
|
|
301
|
+
ry, rx = self._ry(w), w / 2
|
|
302
|
+
top = _ellipse_pts(x + rx, y + ry, rx, ry, math.pi, 2 * math.pi, 24)
|
|
303
|
+
bot = _ellipse_pts(x + rx, y + h - ry, rx, ry, 0, math.pi, 24)
|
|
304
|
+
return top + bot
|
|
305
|
+
|
|
306
|
+
def _path(self, x, y, w, h):
|
|
307
|
+
ry, rx = self._ry(w), w / 2
|
|
308
|
+
return (f"M {_f(x)},{_f(y + ry)} A {_f(rx)},{_f(ry)} 0 0 1 {_f(x + w)},{_f(y + ry)} "
|
|
309
|
+
f"L {_f(x + w)},{_f(y + h - ry)} A {_f(rx)},{_f(ry)} 0 0 1 {_f(x)},{_f(y + h - ry)} Z")
|
|
310
|
+
|
|
311
|
+
def _decorate(self, buf, x, y, w, h):
|
|
312
|
+
from .render_svg import draw_path
|
|
313
|
+
|
|
314
|
+
stroke = self._border_stroke(self.stroke)
|
|
315
|
+
if stroke in (None, "none"):
|
|
316
|
+
return
|
|
317
|
+
ry, rx = self._ry(w), w / 2
|
|
318
|
+
draw_path(buf, f"M {_f(x)},{_f(y + ry)} A {_f(rx)},{_f(ry)} 0 0 0 {_f(x + w)},{_f(y + ry)}",
|
|
319
|
+
stroke=stroke, stroke_width=self.stroke_width, dasharray=self._dash(self.stroke_width))
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class Document(Shape):
|
|
323
|
+
"""Page with a wavy bottom edge (flow-chart document)."""
|
|
324
|
+
|
|
325
|
+
def _amp(self, w: float) -> float:
|
|
326
|
+
return max(2.5, min(6.0, w * 0.045))
|
|
327
|
+
|
|
328
|
+
def _frame_size(self, iw, ih):
|
|
329
|
+
return iw, ih + 2 * self._amp(iw)
|
|
330
|
+
|
|
331
|
+
def _text_rect(self, x, y, w, h):
|
|
332
|
+
return x, y, w, h - 2 * self._amp(w)
|
|
333
|
+
|
|
334
|
+
def _wave(self, x, y, w, h, n=20) -> List[Pt]:
|
|
335
|
+
a = self._amp(w)
|
|
336
|
+
base = y + h - a
|
|
337
|
+
return [(x + w - w * i / n, base - a * math.sin(2 * math.pi * i / n)) for i in range(n + 1)]
|
|
338
|
+
|
|
339
|
+
def _outline(self, x, y, w, h):
|
|
340
|
+
return [(x, y), (x + w, y)] + self._wave(x, y, w, h)
|
|
341
|
+
|
|
342
|
+
def _path(self, x, y, w, h):
|
|
343
|
+
a = self._amp(w)
|
|
344
|
+
b = y + h - a
|
|
345
|
+
k = a * 2.0 # cubic control offset ≈ a sine of amplitude a
|
|
346
|
+
return (f"M {_f(x)},{_f(y)} L {_f(x + w)},{_f(y)} L {_f(x + w)},{_f(b)} "
|
|
347
|
+
f"C {_f(x + w * 0.83)},{_f(b - k)} {_f(x + w * 0.67)},{_f(b - k)} {_f(x + w / 2)},{_f(b)} "
|
|
348
|
+
f"C {_f(x + w * 0.33)},{_f(b + k)} {_f(x + w * 0.17)},{_f(b + k)} {_f(x)},{_f(b)} Z")
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# ---------------------------------------------------------------------------- polygons
|
|
352
|
+
class Trapezoid(Shape):
|
|
353
|
+
"""Trapezoid; ``narrow`` = the short side (top | bottom | left | right), ``ratio`` =
|
|
354
|
+
short / long side, within 0.05..1 (1 = a rectangle; for a point use ``Triangle``). ``narrow="right"`` is the classic encoder (wide input, narrow
|
|
355
|
+
output); ``narrow="left"`` a decoder."""
|
|
356
|
+
|
|
357
|
+
def __init__(self, text: str = "", *, narrow: str = "top", ratio: float = 0.6, **kwargs: Any) -> None:
|
|
358
|
+
if narrow not in ("top", "bottom", "left", "right"):
|
|
359
|
+
raise ValueError("narrow must be top|bottom|left|right")
|
|
360
|
+
if not 0.05 <= ratio <= 1.0:
|
|
361
|
+
raise ValueError("ratio must be within 0.05..1")
|
|
362
|
+
self.narrow = narrow
|
|
363
|
+
self.ratio = float(ratio)
|
|
364
|
+
super().__init__(text, **kwargs)
|
|
365
|
+
|
|
366
|
+
def _frame_size(self, iw, ih):
|
|
367
|
+
k = 1.12 # slack along the parallel-side axis
|
|
368
|
+
t = (1 + 1 / k) / 2 # where the label rectangle's narrow end sits (fraction)
|
|
369
|
+
shrink = 1 - (1 - self.ratio) * t
|
|
370
|
+
if self.narrow in ("left", "right"):
|
|
371
|
+
return iw * k, ih / shrink
|
|
372
|
+
return iw / shrink, ih * k
|
|
373
|
+
|
|
374
|
+
def _text_rect(self, x, y, w, h):
|
|
375
|
+
k = 1.12
|
|
376
|
+
t = (1 + 1 / k) / 2
|
|
377
|
+
shrink = 1 - (1 - self.ratio) * t
|
|
378
|
+
if self.narrow in ("left", "right"):
|
|
379
|
+
iw, ih = w / k, h * shrink
|
|
380
|
+
else:
|
|
381
|
+
iw, ih = w * shrink, h / k
|
|
382
|
+
return x + (w - iw) / 2, y + (h - ih) / 2, iw, ih
|
|
383
|
+
|
|
384
|
+
def _outline(self, x, y, w, h):
|
|
385
|
+
r = self.ratio
|
|
386
|
+
if self.narrow == "top":
|
|
387
|
+
d = w * (1 - r) / 2
|
|
388
|
+
return [(x + d, y), (x + w - d, y), (x + w, y + h), (x, y + h)]
|
|
389
|
+
if self.narrow == "bottom":
|
|
390
|
+
d = w * (1 - r) / 2
|
|
391
|
+
return [(x, y), (x + w, y), (x + w - d, y + h), (x + d, y + h)]
|
|
392
|
+
d = h * (1 - r) / 2
|
|
393
|
+
if self.narrow == "right":
|
|
394
|
+
return [(x, y), (x + w, y + d), (x + w, y + h - d), (x, y + h)]
|
|
395
|
+
return [(x, y + d), (x + w, y), (x + w, y + h), (x, y + h - d)]
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
class Triangle(Shape):
|
|
399
|
+
"""v0.12.0: isosceles triangle; ``direction`` = where the apex points
|
|
400
|
+
(up | down | left | right). The label sits in the wide half (inscribed box ×2)."""
|
|
401
|
+
|
|
402
|
+
def __init__(self, text: str = "", *, direction: str = "up", **kwargs: Any) -> None:
|
|
403
|
+
if direction not in ("up", "down", "left", "right"):
|
|
404
|
+
raise ValueError("Triangle direction must be up|down|left|right")
|
|
405
|
+
self.direction = direction
|
|
406
|
+
super().__init__(text, **kwargs)
|
|
407
|
+
|
|
408
|
+
def _frame_size(self, iw, ih):
|
|
409
|
+
return iw * 2.0, ih * 2.0
|
|
410
|
+
|
|
411
|
+
def _text_rect(self, x, y, w, h):
|
|
412
|
+
d = self.direction
|
|
413
|
+
if d == "up":
|
|
414
|
+
return x + w / 4, y + h / 2, w / 2, h / 2
|
|
415
|
+
if d == "down":
|
|
416
|
+
return x + w / 4, y, w / 2, h / 2
|
|
417
|
+
if d == "left":
|
|
418
|
+
return x + w / 2, y + h / 4, w / 2, h / 2
|
|
419
|
+
return x, y + h / 4, w / 2, h / 2
|
|
420
|
+
|
|
421
|
+
def _outline(self, x, y, w, h):
|
|
422
|
+
d = self.direction
|
|
423
|
+
if d == "up":
|
|
424
|
+
return [(x + w / 2, y), (x + w, y + h), (x, y + h)]
|
|
425
|
+
if d == "down":
|
|
426
|
+
return [(x, y), (x + w, y), (x + w / 2, y + h)]
|
|
427
|
+
if d == "left":
|
|
428
|
+
return [(x, y + h / 2), (x + w, y), (x + w, y + h)]
|
|
429
|
+
return [(x, y), (x + w, y + h / 2), (x, y + h)]
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
class Polygon(Shape):
|
|
433
|
+
"""v0.12.0: any closed outline — ``Polygon(points=[(0.5, 0), (1, 1), (0, 1)])`` in unit
|
|
434
|
+
coordinates of the node box (0..1, y down). ``text_box=(fx, fy, fw, fh)`` (unit
|
|
435
|
+
coordinates too) is where the label goes; the node grows so the label fits it
|
|
436
|
+
(default: the central half, ``(0.25, 0.25, 0.5, 0.5)``)."""
|
|
437
|
+
|
|
438
|
+
def __init__(self, text: str = "", *, points: Sequence[Pt] = (), text_box: Optional[Sequence[float]] = None,
|
|
439
|
+
**kwargs: Any) -> None:
|
|
440
|
+
pts = [(float(a), float(b)) for a, b in points]
|
|
441
|
+
if len(pts) < 3:
|
|
442
|
+
raise ValueError("Polygon needs at least 3 points")
|
|
443
|
+
if any(not (-1e-9 <= a <= 1 + 1e-9 and -1e-9 <= b <= 1 + 1e-9) for a, b in pts):
|
|
444
|
+
raise ValueError("Polygon points must use unit coordinates within 0..1 (x right, y down); set w=/h= for physical size")
|
|
445
|
+
tb = tuple(float(v) for v in (text_box or (0.25, 0.25, 0.5, 0.5)))
|
|
446
|
+
if len(tb) != 4 or tb[2] <= 0 or tb[3] <= 0:
|
|
447
|
+
raise ValueError("text_box must be (fx, fy, fw, fh) with fw, fh > 0")
|
|
448
|
+
self.points, self.text_box = pts, tb
|
|
449
|
+
super().__init__(text, **kwargs)
|
|
450
|
+
|
|
451
|
+
def _frame_size(self, iw, ih):
|
|
452
|
+
return iw / self.text_box[2], ih / self.text_box[3]
|
|
453
|
+
|
|
454
|
+
def _text_rect(self, x, y, w, h):
|
|
455
|
+
fx, fy, fw, fh = self.text_box
|
|
456
|
+
return x + fx * w, y + fy * h, fw * w, fh * h
|
|
457
|
+
|
|
458
|
+
def _outline(self, x, y, w, h):
|
|
459
|
+
return [(x + a * w, y + b * h) for a, b in self.points]
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
class Parallelogram(Shape):
|
|
463
|
+
"""Slanted box (data / IO). ``skew`` = horizontal offset as a fraction of height."""
|
|
464
|
+
|
|
465
|
+
def __init__(self, text: str = "", *, skew: float = 0.35, **kwargs: Any) -> None:
|
|
466
|
+
self.skew = float(skew)
|
|
467
|
+
super().__init__(text, **kwargs)
|
|
468
|
+
|
|
469
|
+
def _frame_size(self, iw, ih):
|
|
470
|
+
return iw + 2 * self.skew * ih, ih
|
|
471
|
+
|
|
472
|
+
def _text_rect(self, x, y, w, h):
|
|
473
|
+
d = self.skew * h
|
|
474
|
+
return x + d, y, w - 2 * d, h
|
|
475
|
+
|
|
476
|
+
def _outline(self, x, y, w, h):
|
|
477
|
+
d = self.skew * h
|
|
478
|
+
return [(x + d, y), (x + w, y), (x + w - d, y + h), (x, y + h)]
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
class Hexagon(Shape):
|
|
482
|
+
"""Flat-topped hexagon (pointed left / right). ``cut`` = point depth / height."""
|
|
483
|
+
|
|
484
|
+
def __init__(self, text: str = "", *, cut: float = 0.3, **kwargs: Any) -> None:
|
|
485
|
+
self.cut = float(cut)
|
|
486
|
+
super().__init__(text, **kwargs)
|
|
487
|
+
|
|
488
|
+
def _frame_size(self, iw, ih):
|
|
489
|
+
return iw + 2 * self.cut * ih, ih
|
|
490
|
+
|
|
491
|
+
def _text_rect(self, x, y, w, h):
|
|
492
|
+
c = self.cut * h
|
|
493
|
+
return x + c, y, w - 2 * c, h
|
|
494
|
+
|
|
495
|
+
def _outline(self, x, y, w, h):
|
|
496
|
+
c = self.cut * h
|
|
497
|
+
return [(x + c, y), (x + w - c, y), (x + w, y + h / 2), (x + w - c, y + h),
|
|
498
|
+
(x + c, y + h), (x, y + h / 2)]
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
class Diamond(Shape):
|
|
502
|
+
"""Decision rhombus (label inscribed: 2 × label box)."""
|
|
503
|
+
|
|
504
|
+
def _frame_size(self, iw, ih):
|
|
505
|
+
return iw * 2, ih * 2
|
|
506
|
+
|
|
507
|
+
def _text_rect(self, x, y, w, h):
|
|
508
|
+
return x + w / 4, y + h / 4, w / 2, h / 2
|
|
509
|
+
|
|
510
|
+
def _outline(self, x, y, w, h):
|
|
511
|
+
return [(x + w / 2, y), (x + w, y + h / 2), (x + w / 2, y + h), (x, y + h / 2)]
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
_TAILS = ("bottom-left", "bottom-right", "top-left", "top-right", "left", "right")
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
class ChatBubble(Shape):
|
|
518
|
+
"""Speech bubble: rounded body + a tail (``tail`` = bottom-left | bottom-right |
|
|
519
|
+
top-left | top-right | left | right; ``tail_size`` px)."""
|
|
520
|
+
|
|
521
|
+
def __init__(self, text: str = "", *, tail: str = "bottom-left", tail_size: float = 9.0,
|
|
522
|
+
**kwargs: Any) -> None:
|
|
523
|
+
if tail not in _TAILS:
|
|
524
|
+
raise ValueError(f"tail must be one of {_TAILS}")
|
|
525
|
+
self.tail = tail
|
|
526
|
+
self.tail_size = float(tail_size)
|
|
527
|
+
kwargs.setdefault("rounded", True)
|
|
528
|
+
super().__init__(text, **kwargs)
|
|
529
|
+
|
|
530
|
+
def _frame_size(self, iw, ih):
|
|
531
|
+
t = self.tail_size
|
|
532
|
+
return (iw + t, ih) if self.tail in ("left", "right") else (iw, ih + t)
|
|
533
|
+
|
|
534
|
+
def _text_rect(self, x, y, w, h):
|
|
535
|
+
t = self.tail_size
|
|
536
|
+
if self.tail == "left":
|
|
537
|
+
return x + t, y, w - t, h
|
|
538
|
+
if self.tail == "right":
|
|
539
|
+
return x, y, w - t, h
|
|
540
|
+
if self.tail.startswith("top"):
|
|
541
|
+
return x, y + t, w, h - t
|
|
542
|
+
return x, y, w, h - t
|
|
543
|
+
|
|
544
|
+
def _r(self, w, h) -> float:
|
|
545
|
+
r = self.radius if self.radius is not None else max(self.theme.corner_radius, 6.0)
|
|
546
|
+
return min(r, w / 2, h / 2) if self.rounded else 0.0
|
|
547
|
+
|
|
548
|
+
def _outline(self, x, y, w, h):
|
|
549
|
+
bx, by, bw, bh = self._text_rect(x, y, w, h)
|
|
550
|
+
r = self._r(bw, bh)
|
|
551
|
+
t = self.tail_size
|
|
552
|
+
pts: List[Pt] = []
|
|
553
|
+
|
|
554
|
+
def arc(cx, cy, a0, a1):
|
|
555
|
+
pts.extend(_ellipse_pts(cx, cy, r, r, a0, a1, 6) if r > 0 else [(cx, cy)])
|
|
556
|
+
|
|
557
|
+
# clockwise from the top-left corner, inserting the tail on its side
|
|
558
|
+
arc(bx + r, by + r, math.pi, 1.5 * math.pi)
|
|
559
|
+
if self.tail.startswith("top"):
|
|
560
|
+
tx = bx + (bw * 0.25 if self.tail.endswith("left") else bw * 0.75)
|
|
561
|
+
pts += [(tx - t * 0.6, by), (tx - t * 0.2 if self.tail.endswith("left") else tx + t * 0.6, by - t),
|
|
562
|
+
(tx + t * 0.6, by)]
|
|
563
|
+
arc(bx + bw - r, by + r, 1.5 * math.pi, 2 * math.pi)
|
|
564
|
+
if self.tail == "right":
|
|
565
|
+
ty = by + bh / 2
|
|
566
|
+
pts += [(bx + bw, ty - t * 0.6), (bx + bw + t, ty + t * 0.3), (bx + bw, ty + t * 0.6)]
|
|
567
|
+
arc(bx + bw - r, by + bh - r, 0, 0.5 * math.pi)
|
|
568
|
+
if self.tail.startswith("bottom"):
|
|
569
|
+
tx = bx + (bw * 0.25 if self.tail.endswith("left") else bw * 0.75)
|
|
570
|
+
pts += [(tx + t * 0.6, by + bh), (tx - t * 0.6 if self.tail.endswith("left") else tx + t * 0.2, by + bh + t),
|
|
571
|
+
(tx - t * 0.6, by + bh)]
|
|
572
|
+
arc(bx + r, by + bh - r, 0.5 * math.pi, math.pi)
|
|
573
|
+
if self.tail == "left":
|
|
574
|
+
ty = by + bh / 2
|
|
575
|
+
pts += [(bx, ty + t * 0.6), (bx - t, ty + t * 0.3), (bx, ty - t * 0.6)]
|
|
576
|
+
return pts
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
class BlockArrow(Shape):
|
|
580
|
+
"""Fat arrow with the label in its shaft. ``direction`` right | left | up | down |
|
|
581
|
+
both (↔) | both-vertical (↕); ``shaft`` = shaft thickness / total thickness."""
|
|
582
|
+
|
|
583
|
+
def __init__(self, text: str = "", *, direction: str = "right", shaft: float = 0.58,
|
|
584
|
+
head: Optional[float] = None, **kwargs: Any) -> None:
|
|
585
|
+
if direction not in ("right", "left", "up", "down", "both", "both-vertical"):
|
|
586
|
+
raise ValueError("direction must be right|left|up|down|both|both-vertical")
|
|
587
|
+
self.direction = direction
|
|
588
|
+
self.shaft = float(shaft)
|
|
589
|
+
self.head = head
|
|
590
|
+
super().__init__(text, **kwargs)
|
|
591
|
+
|
|
592
|
+
def _horizontal(self) -> bool:
|
|
593
|
+
return self.direction in ("right", "left", "both")
|
|
594
|
+
|
|
595
|
+
def _heads(self) -> int:
|
|
596
|
+
return 2 if self.direction.startswith("both") else 1
|
|
597
|
+
|
|
598
|
+
def _hl(self, thick: float) -> float:
|
|
599
|
+
return self.head if self.head is not None else thick * 0.55
|
|
600
|
+
|
|
601
|
+
def _frame_size(self, iw, ih):
|
|
602
|
+
if self._horizontal():
|
|
603
|
+
th = ih / self.shaft
|
|
604
|
+
return iw + self._heads() * self._hl(th), th
|
|
605
|
+
tw = iw / self.shaft
|
|
606
|
+
return tw, ih + self._heads() * self._hl(tw)
|
|
607
|
+
|
|
608
|
+
def _text_rect(self, x, y, w, h):
|
|
609
|
+
if self._horizontal():
|
|
610
|
+
hl, s = self._hl(h), h * self.shaft
|
|
611
|
+
x0 = x + (hl if self.direction in ("left", "both") else 0)
|
|
612
|
+
return x0, y + (h - s) / 2, w - self._heads() * hl, s
|
|
613
|
+
hl, s = self._hl(w), w * self.shaft
|
|
614
|
+
y0 = y + (hl if self.direction in ("up", "both-vertical") else 0)
|
|
615
|
+
return x + (w - s) / 2, y0, s, h - self._heads() * hl
|
|
616
|
+
|
|
617
|
+
def _outline(self, x, y, w, h):
|
|
618
|
+
if self._horizontal():
|
|
619
|
+
hl = self._hl(h)
|
|
620
|
+
s0, s1 = y + h * (1 - self.shaft) / 2, y + h * (1 + self.shaft) / 2
|
|
621
|
+
cy = y + h / 2
|
|
622
|
+
L = x + (hl if self.direction in ("left", "both") else 0)
|
|
623
|
+
R = x + w - (hl if self.direction in ("right", "both") else 0)
|
|
624
|
+
right = [(R, s0), (R, y), (x + w, cy), (R, y + h), (R, s1)] if self.direction in ("right", "both") \
|
|
625
|
+
else [(R, s0), (R, s1)]
|
|
626
|
+
left = [(L, s1), (L, y + h), (x, cy), (L, y), (L, s0)] if self.direction in ("left", "both") \
|
|
627
|
+
else [(L, s1), (L, s0)]
|
|
628
|
+
return right + left
|
|
629
|
+
hl = self._hl(w)
|
|
630
|
+
s0, s1 = x + w * (1 - self.shaft) / 2, x + w * (1 + self.shaft) / 2
|
|
631
|
+
cx = x + w / 2
|
|
632
|
+
T = y + (hl if self.direction in ("up", "both-vertical") else 0)
|
|
633
|
+
B = y + h - (hl if self.direction in ("down", "both-vertical") else 0)
|
|
634
|
+
top = [(s0, T), (x, T), (cx, y), (x + w, T), (s1, T)] if self.direction in ("up", "both-vertical") \
|
|
635
|
+
else [(s0, T), (s1, T)]
|
|
636
|
+
bot = [(s1, B), (x + w, B), (cx, y + h), (x, B), (s0, B)] if self.direction in ("down", "both-vertical") \
|
|
637
|
+
else [(s1, B), (s0, B)]
|
|
638
|
+
return top + bot
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
__all__ = ["BlockArrow", "ChatBubble", "Circle", "Cylinder", "Diamond", "Document", "Ellipse",
|
|
642
|
+
"Hexagon", "OpCircle", "Parallelogram", "Shape", "Trapezoid", "ray_hit"]
|