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/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"]