figkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- figkit/__init__.py +114 -0
- figkit/audit.py +611 -0
- figkit/colors.py +304 -0
- figkit/component.py +110 -0
- figkit/components.py +727 -0
- figkit/connectors.py +689 -0
- figkit/core.py +1021 -0
- figkit/export.py +301 -0
- figkit/figure.py +312 -0
- figkit/fonts.py +462 -0
- figkit/frame.py +376 -0
- figkit/geom.py +497 -0
- figkit/image.py +308 -0
- figkit/layout.py +529 -0
- figkit/mathtext.py +320 -0
- figkit/paint.py +136 -0
- figkit/py.typed +0 -0
- figkit/shapes.py +764 -0
- figkit/style.py +502 -0
- figkit/svgdoc.py +133 -0
- figkit/svgpath.py +470 -0
- figkit/text.py +625 -0
- figkit/themes.py +163 -0
- figkit-0.1.0.dist-info/METADATA +277 -0
- figkit-0.1.0.dist-info/RECORD +28 -0
- figkit-0.1.0.dist-info/WHEEL +5 -0
- figkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- figkit-0.1.0.dist-info/top_level.txt +1 -0
figkit/svgpath.py
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
"""A small, dependency-free SVG path toolkit.
|
|
2
|
+
|
|
3
|
+
Enough to parse, transform, measure and flatten path data — which is what we
|
|
4
|
+
need for accurate bounding boxes, arrow geometry and embedding external SVGs.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"parse_path", "abs_segments", "path_bbox", "transform_path_data",
|
|
14
|
+
"translate_path_data", "scale_path_data", "flatten_path", "path_length",
|
|
15
|
+
"point_at", "fmt", "path_from_points", "rounded_polyline",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
_TOKEN_RE = re.compile(r"([MmZzLlHhVvCcSsQqTtAa])|(-?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?)")
|
|
19
|
+
|
|
20
|
+
_ARGS = {"M": 2, "L": 2, "H": 1, "V": 1, "C": 6, "S": 4, "Q": 4, "T": 2,
|
|
21
|
+
"A": 7, "Z": 0}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def fmt(v) -> str:
|
|
25
|
+
"""Compact number formatting for path/attribute output."""
|
|
26
|
+
if isinstance(v, str):
|
|
27
|
+
return v
|
|
28
|
+
v = float(v)
|
|
29
|
+
if v != v or v in (float("inf"), float("-inf")):
|
|
30
|
+
return "0"
|
|
31
|
+
if abs(v - round(v)) < 5e-5:
|
|
32
|
+
return str(int(round(v)))
|
|
33
|
+
s = f"{v:.4f}".rstrip("0").rstrip(".")
|
|
34
|
+
return s or "0"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_path(d: str) -> list:
|
|
38
|
+
"""Tokenise path data into ``[(command, [numbers...]), ...]``.
|
|
39
|
+
|
|
40
|
+
Repeated coordinate sets are expanded into separate commands, and an
|
|
41
|
+
implicit ``L``/``l`` after ``M``/``m`` is made explicit.
|
|
42
|
+
"""
|
|
43
|
+
if not d:
|
|
44
|
+
return []
|
|
45
|
+
out = []
|
|
46
|
+
cmd = None
|
|
47
|
+
nums: list = []
|
|
48
|
+
for m in _TOKEN_RE.finditer(d):
|
|
49
|
+
if m.group(1):
|
|
50
|
+
if cmd is not None:
|
|
51
|
+
out.extend(_emit(cmd, nums))
|
|
52
|
+
cmd = m.group(1)
|
|
53
|
+
nums = []
|
|
54
|
+
else:
|
|
55
|
+
nums.append(float(m.group(2)))
|
|
56
|
+
if cmd is not None:
|
|
57
|
+
out.extend(_emit(cmd, nums))
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _emit(cmd: str, nums: list) -> list:
|
|
62
|
+
n = _ARGS[cmd.upper()]
|
|
63
|
+
if n == 0:
|
|
64
|
+
return [(cmd, [])]
|
|
65
|
+
if not nums:
|
|
66
|
+
return []
|
|
67
|
+
out = []
|
|
68
|
+
first = True
|
|
69
|
+
for i in range(0, len(nums) - n + 1, n):
|
|
70
|
+
c = cmd
|
|
71
|
+
if not first and cmd in "Mm":
|
|
72
|
+
c = "L" if cmd == "M" else "l"
|
|
73
|
+
out.append((c, nums[i:i + n]))
|
|
74
|
+
first = False
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def abs_segments(d: str) -> list:
|
|
79
|
+
"""Normalise to absolute segments.
|
|
80
|
+
|
|
81
|
+
Yields ``("M", x, y)``, ``("L", x, y)``, ``("C", x1,y1,x2,y2,x,y)``,
|
|
82
|
+
``("Q", x1,y1,x,y)`` and ``("Z",)``. Arcs are converted to cubics.
|
|
83
|
+
"""
|
|
84
|
+
segs = []
|
|
85
|
+
cx = cy = 0.0 # current point
|
|
86
|
+
sx = sy = 0.0 # subpath start
|
|
87
|
+
prev_c2 = None # previous cubic control point (for S)
|
|
88
|
+
prev_q1 = None # previous quadratic control point (for T)
|
|
89
|
+
for cmd, a in parse_path(d):
|
|
90
|
+
up = cmd.upper()
|
|
91
|
+
rel = cmd.islower()
|
|
92
|
+
if up == "M":
|
|
93
|
+
x, y = (cx + a[0], cy + a[1]) if rel else (a[0], a[1])
|
|
94
|
+
segs.append(("M", x, y))
|
|
95
|
+
cx, cy = sx, sy = x, y
|
|
96
|
+
prev_c2 = prev_q1 = None
|
|
97
|
+
elif up == "L":
|
|
98
|
+
x, y = (cx + a[0], cy + a[1]) if rel else (a[0], a[1])
|
|
99
|
+
segs.append(("L", x, y))
|
|
100
|
+
cx, cy = x, y
|
|
101
|
+
prev_c2 = prev_q1 = None
|
|
102
|
+
elif up == "H":
|
|
103
|
+
x = cx + a[0] if rel else a[0]
|
|
104
|
+
segs.append(("L", x, cy))
|
|
105
|
+
cx = x
|
|
106
|
+
prev_c2 = prev_q1 = None
|
|
107
|
+
elif up == "V":
|
|
108
|
+
y = cy + a[0] if rel else a[0]
|
|
109
|
+
segs.append(("L", cx, y))
|
|
110
|
+
cy = y
|
|
111
|
+
prev_c2 = prev_q1 = None
|
|
112
|
+
elif up == "C":
|
|
113
|
+
pts = [(cx + a[i] if rel else a[i], cy + a[i + 1] if rel else a[i + 1])
|
|
114
|
+
for i in (0, 2, 4)]
|
|
115
|
+
segs.append(("C", pts[0][0], pts[0][1], pts[1][0], pts[1][1],
|
|
116
|
+
pts[2][0], pts[2][1]))
|
|
117
|
+
prev_c2 = pts[1]
|
|
118
|
+
cx, cy = pts[2]
|
|
119
|
+
prev_q1 = None
|
|
120
|
+
elif up == "S":
|
|
121
|
+
c1 = (2 * cx - prev_c2[0], 2 * cy - prev_c2[1]) if prev_c2 else (cx, cy)
|
|
122
|
+
pts = [(cx + a[i] if rel else a[i], cy + a[i + 1] if rel else a[i + 1])
|
|
123
|
+
for i in (0, 2)]
|
|
124
|
+
segs.append(("C", c1[0], c1[1], pts[0][0], pts[0][1],
|
|
125
|
+
pts[1][0], pts[1][1]))
|
|
126
|
+
prev_c2 = pts[0]
|
|
127
|
+
cx, cy = pts[1]
|
|
128
|
+
prev_q1 = None
|
|
129
|
+
elif up == "Q":
|
|
130
|
+
pts = [(cx + a[i] if rel else a[i], cy + a[i + 1] if rel else a[i + 1])
|
|
131
|
+
for i in (0, 2)]
|
|
132
|
+
segs.append(("Q", pts[0][0], pts[0][1], pts[1][0], pts[1][1]))
|
|
133
|
+
prev_q1 = pts[0]
|
|
134
|
+
cx, cy = pts[1]
|
|
135
|
+
prev_c2 = None
|
|
136
|
+
elif up == "T":
|
|
137
|
+
q1 = (2 * cx - prev_q1[0], 2 * cy - prev_q1[1]) if prev_q1 else (cx, cy)
|
|
138
|
+
x, y = (cx + a[0], cy + a[1]) if rel else (a[0], a[1])
|
|
139
|
+
segs.append(("Q", q1[0], q1[1], x, y))
|
|
140
|
+
prev_q1 = q1
|
|
141
|
+
cx, cy = x, y
|
|
142
|
+
prev_c2 = None
|
|
143
|
+
elif up == "A":
|
|
144
|
+
rx, ry, rot, laf, sf = a[0], a[1], a[2], a[3], a[4]
|
|
145
|
+
x, y = (cx + a[5], cy + a[6]) if rel else (a[5], a[6])
|
|
146
|
+
segs.extend(_arc_to_cubics(cx, cy, rx, ry, rot, laf, sf, x, y))
|
|
147
|
+
cx, cy = x, y
|
|
148
|
+
prev_c2 = prev_q1 = None
|
|
149
|
+
elif up == "Z":
|
|
150
|
+
segs.append(("Z",))
|
|
151
|
+
cx, cy = sx, sy
|
|
152
|
+
prev_c2 = prev_q1 = None
|
|
153
|
+
return segs
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _arc_to_cubics(x1, y1, rx, ry, rot_deg, large_arc, sweep, x2, y2) -> list:
|
|
157
|
+
"""Convert an SVG elliptical arc into a series of cubic segments."""
|
|
158
|
+
if rx == 0 or ry == 0 or (x1 == x2 and y1 == y2):
|
|
159
|
+
return [("L", x2, y2)]
|
|
160
|
+
rx, ry = abs(rx), abs(ry)
|
|
161
|
+
phi = math.radians(rot_deg)
|
|
162
|
+
cosp, sinp = math.cos(phi), math.sin(phi)
|
|
163
|
+
dx2, dy2 = (x1 - x2) / 2.0, (y1 - y2) / 2.0
|
|
164
|
+
x1p = cosp * dx2 + sinp * dy2
|
|
165
|
+
y1p = -sinp * dx2 + cosp * dy2
|
|
166
|
+
lam = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry)
|
|
167
|
+
if lam > 1:
|
|
168
|
+
s = math.sqrt(lam)
|
|
169
|
+
rx, ry = rx * s, ry * s
|
|
170
|
+
num = rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p
|
|
171
|
+
den = rx * rx * y1p * y1p + ry * ry * x1p * x1p
|
|
172
|
+
factor = math.sqrt(max(0.0, num / den)) if den else 0.0
|
|
173
|
+
if bool(large_arc) == bool(sweep):
|
|
174
|
+
factor = -factor
|
|
175
|
+
cxp = factor * rx * y1p / ry
|
|
176
|
+
cyp = -factor * ry * x1p / rx
|
|
177
|
+
cx = cosp * cxp - sinp * cyp + (x1 + x2) / 2.0
|
|
178
|
+
cy = sinp * cxp + cosp * cyp + (y1 + y2) / 2.0
|
|
179
|
+
|
|
180
|
+
def angle(ux, uy, vx, vy):
|
|
181
|
+
dot = ux * vx + uy * vy
|
|
182
|
+
n = math.hypot(ux, uy) * math.hypot(vx, vy)
|
|
183
|
+
if n == 0:
|
|
184
|
+
return 0.0
|
|
185
|
+
a = math.acos(max(-1.0, min(1.0, dot / n)))
|
|
186
|
+
return -a if (ux * vy - uy * vx) < 0 else a
|
|
187
|
+
|
|
188
|
+
theta1 = angle(1, 0, (x1p - cxp) / rx, (y1p - cyp) / ry)
|
|
189
|
+
dtheta = angle((x1p - cxp) / rx, (y1p - cyp) / ry,
|
|
190
|
+
(-x1p - cxp) / rx, (-y1p - cyp) / ry)
|
|
191
|
+
if not sweep and dtheta > 0:
|
|
192
|
+
dtheta -= 2 * math.pi
|
|
193
|
+
elif sweep and dtheta < 0:
|
|
194
|
+
dtheta += 2 * math.pi
|
|
195
|
+
|
|
196
|
+
n_segs = max(1, int(math.ceil(abs(dtheta) / (math.pi / 2))))
|
|
197
|
+
delta = dtheta / n_segs
|
|
198
|
+
t = 4.0 / 3.0 * math.tan(delta / 4.0)
|
|
199
|
+
out = []
|
|
200
|
+
th = theta1
|
|
201
|
+
px = x1
|
|
202
|
+
py = y1
|
|
203
|
+
for _ in range(n_segs):
|
|
204
|
+
cos1, sin1 = math.cos(th), math.sin(th)
|
|
205
|
+
th2 = th + delta
|
|
206
|
+
cos2, sin2 = math.cos(th2), math.sin(th2)
|
|
207
|
+
ex = cx + rx * cosp * cos2 - ry * sinp * sin2
|
|
208
|
+
ey = cy + rx * sinp * cos2 + ry * cosp * sin2
|
|
209
|
+
d1x = -rx * cosp * sin1 - ry * sinp * cos1
|
|
210
|
+
d1y = -rx * sinp * sin1 + ry * cosp * cos1
|
|
211
|
+
d2x = -rx * cosp * sin2 - ry * sinp * cos2
|
|
212
|
+
d2y = -rx * sinp * sin2 + ry * cosp * cos2
|
|
213
|
+
out.append(("C", px + t * d1x, py + t * d1y,
|
|
214
|
+
ex - t * d2x, ey - t * d2y, ex, ey))
|
|
215
|
+
px, py, th = ex, ey, th2
|
|
216
|
+
return out
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# --------------------------------------------------------------------------
|
|
220
|
+
# Bounding box (exact, using Bezier extrema)
|
|
221
|
+
# --------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
def _bez_extrema(p0, p1, p2, p3=None) -> list:
|
|
224
|
+
"""Parameter values where a Bezier component reaches an extremum."""
|
|
225
|
+
ts = []
|
|
226
|
+
if p3 is None: # quadratic
|
|
227
|
+
den = p0 - 2 * p1 + p2
|
|
228
|
+
if abs(den) > 1e-12:
|
|
229
|
+
t = (p0 - p1) / den
|
|
230
|
+
if 0 < t < 1:
|
|
231
|
+
ts.append(t)
|
|
232
|
+
return ts
|
|
233
|
+
a = -p0 + 3 * p1 - 3 * p2 + p3
|
|
234
|
+
b = 2 * (p0 - 2 * p1 + p2)
|
|
235
|
+
c = p1 - p0
|
|
236
|
+
if abs(a) < 1e-12:
|
|
237
|
+
if abs(b) > 1e-12:
|
|
238
|
+
t = -c / b
|
|
239
|
+
if 0 < t < 1:
|
|
240
|
+
ts.append(t)
|
|
241
|
+
return ts
|
|
242
|
+
disc = b * b - 4 * a * c
|
|
243
|
+
if disc < 0:
|
|
244
|
+
return ts
|
|
245
|
+
sq = math.sqrt(disc)
|
|
246
|
+
for t in ((-b + sq) / (2 * a), (-b - sq) / (2 * a)):
|
|
247
|
+
if 0 < t < 1:
|
|
248
|
+
ts.append(t)
|
|
249
|
+
return ts
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _bez_at(t, p0, p1, p2, p3=None) -> float:
|
|
253
|
+
if p3 is None:
|
|
254
|
+
mt = 1 - t
|
|
255
|
+
return mt * mt * p0 + 2 * mt * t * p1 + t * t * p2
|
|
256
|
+
mt = 1 - t
|
|
257
|
+
return (mt ** 3 * p0 + 3 * mt * mt * t * p1
|
|
258
|
+
+ 3 * mt * t * t * p2 + t ** 3 * p3)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def path_bbox(d: str) -> tuple:
|
|
262
|
+
"""Exact ``(x0, y0, x1, y1)`` bounds of path data (ignoring stroke width)."""
|
|
263
|
+
xs: list = []
|
|
264
|
+
ys: list = []
|
|
265
|
+
cx = cy = 0.0
|
|
266
|
+
sx = sy = 0.0
|
|
267
|
+
started = False
|
|
268
|
+
for seg in abs_segments(d):
|
|
269
|
+
k = seg[0]
|
|
270
|
+
if k == "M":
|
|
271
|
+
cx, cy = seg[1], seg[2]
|
|
272
|
+
sx, sy = cx, cy
|
|
273
|
+
xs.append(cx)
|
|
274
|
+
ys.append(cy)
|
|
275
|
+
started = True
|
|
276
|
+
elif k == "L":
|
|
277
|
+
xs.extend((cx, seg[1]))
|
|
278
|
+
ys.extend((cy, seg[2]))
|
|
279
|
+
cx, cy = seg[1], seg[2]
|
|
280
|
+
elif k == "C":
|
|
281
|
+
x1, y1, x2, y2, x, y = seg[1:]
|
|
282
|
+
xs.extend((cx, x))
|
|
283
|
+
ys.extend((cy, y))
|
|
284
|
+
for t in _bez_extrema(cx, x1, x2, x):
|
|
285
|
+
xs.append(_bez_at(t, cx, x1, x2, x))
|
|
286
|
+
for t in _bez_extrema(cy, y1, y2, y):
|
|
287
|
+
ys.append(_bez_at(t, cy, y1, y2, y))
|
|
288
|
+
cx, cy = x, y
|
|
289
|
+
elif k == "Q":
|
|
290
|
+
x1, y1, x, y = seg[1:]
|
|
291
|
+
xs.extend((cx, x))
|
|
292
|
+
ys.extend((cy, y))
|
|
293
|
+
for t in _bez_extrema(cx, x1, x):
|
|
294
|
+
xs.append(_bez_at(t, cx, x1, x))
|
|
295
|
+
for t in _bez_extrema(cy, y1, y):
|
|
296
|
+
ys.append(_bez_at(t, cy, y1, y))
|
|
297
|
+
cx, cy = x, y
|
|
298
|
+
elif k == "Z":
|
|
299
|
+
cx, cy = sx, sy
|
|
300
|
+
if not started or not xs:
|
|
301
|
+
return (0.0, 0.0, 0.0, 0.0)
|
|
302
|
+
return (min(xs), min(ys), max(xs), max(ys))
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# --------------------------------------------------------------------------
|
|
306
|
+
# Transforms
|
|
307
|
+
# --------------------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
def transform_path_data(d: str, affine) -> str:
|
|
310
|
+
"""Apply an :class:`~figkit.geom.Affine` to path data, returning new data."""
|
|
311
|
+
if not d:
|
|
312
|
+
return ""
|
|
313
|
+
ap = affine.apply
|
|
314
|
+
out = []
|
|
315
|
+
for seg in abs_segments(d):
|
|
316
|
+
k = seg[0]
|
|
317
|
+
if k == "Z":
|
|
318
|
+
out.append("Z")
|
|
319
|
+
continue
|
|
320
|
+
coords = seg[1:]
|
|
321
|
+
pts = [ap((coords[i], coords[i + 1])) for i in range(0, len(coords), 2)]
|
|
322
|
+
body = " ".join(f"{fmt(p.x)} {fmt(p.y)}" for p in pts)
|
|
323
|
+
out.append(f"{k}{body}")
|
|
324
|
+
return "".join(out)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def translate_path_data(d: str, dx: float, dy: float) -> str:
|
|
328
|
+
from .geom import Affine
|
|
329
|
+
if dx == 0 and dy == 0:
|
|
330
|
+
return d
|
|
331
|
+
return transform_path_data(d, Affine.translate(dx, dy))
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def scale_path_data(d: str, sx: float, sy: float = None) -> str:
|
|
335
|
+
from .geom import Affine
|
|
336
|
+
sy = sx if sy is None else sy
|
|
337
|
+
if sx == 1 and sy == 1:
|
|
338
|
+
return d
|
|
339
|
+
return transform_path_data(d, Affine.scale(sx, sy))
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# --------------------------------------------------------------------------
|
|
343
|
+
# Flattening / measuring
|
|
344
|
+
# --------------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
def flatten_path(d: str, steps: int = 16) -> list:
|
|
347
|
+
"""Approximate path data as a list of polylines (lists of ``(x, y)``)."""
|
|
348
|
+
polys: list = []
|
|
349
|
+
cur: list = []
|
|
350
|
+
cx = cy = sx = sy = 0.0
|
|
351
|
+
for seg in abs_segments(d):
|
|
352
|
+
k = seg[0]
|
|
353
|
+
if k == "M":
|
|
354
|
+
if len(cur) > 1:
|
|
355
|
+
polys.append(cur)
|
|
356
|
+
cx, cy = seg[1], seg[2]
|
|
357
|
+
sx, sy = cx, cy
|
|
358
|
+
cur = [(cx, cy)]
|
|
359
|
+
elif k == "L":
|
|
360
|
+
cx, cy = seg[1], seg[2]
|
|
361
|
+
cur.append((cx, cy))
|
|
362
|
+
elif k == "C":
|
|
363
|
+
x1, y1, x2, y2, x, y = seg[1:]
|
|
364
|
+
for i in range(1, steps + 1):
|
|
365
|
+
t = i / steps
|
|
366
|
+
cur.append((_bez_at(t, cx, x1, x2, x), _bez_at(t, cy, y1, y2, y)))
|
|
367
|
+
cx, cy = x, y
|
|
368
|
+
elif k == "Q":
|
|
369
|
+
x1, y1, x, y = seg[1:]
|
|
370
|
+
for i in range(1, steps + 1):
|
|
371
|
+
t = i / steps
|
|
372
|
+
cur.append((_bez_at(t, cx, x1, x), _bez_at(t, cy, y1, y)))
|
|
373
|
+
cx, cy = x, y
|
|
374
|
+
elif k == "Z":
|
|
375
|
+
cur.append((sx, sy))
|
|
376
|
+
polys.append(cur)
|
|
377
|
+
cx, cy = sx, sy
|
|
378
|
+
cur = [(cx, cy)]
|
|
379
|
+
if len(cur) > 1:
|
|
380
|
+
polys.append(cur)
|
|
381
|
+
return polys
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def path_length(d: str, steps: int = 24) -> float:
|
|
385
|
+
total = 0.0
|
|
386
|
+
for poly in flatten_path(d, steps):
|
|
387
|
+
for i in range(len(poly) - 1):
|
|
388
|
+
total += math.hypot(poly[i + 1][0] - poly[i][0],
|
|
389
|
+
poly[i + 1][1] - poly[i][1])
|
|
390
|
+
return total
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def point_at(d: str, t: float, steps: int = 24) -> tuple:
|
|
394
|
+
"""``(point, unit_direction)`` at fraction ``t`` along the whole path."""
|
|
395
|
+
from .geom import point_along
|
|
396
|
+
pts: list = []
|
|
397
|
+
for poly in flatten_path(d, steps):
|
|
398
|
+
pts.extend(poly)
|
|
399
|
+
if not pts:
|
|
400
|
+
from .geom import Point
|
|
401
|
+
return Point(0, 0), Point(1, 0)
|
|
402
|
+
return point_along(pts, t)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
# --------------------------------------------------------------------------
|
|
406
|
+
# Building path data
|
|
407
|
+
# --------------------------------------------------------------------------
|
|
408
|
+
|
|
409
|
+
def path_from_points(points, close: bool = False) -> str:
|
|
410
|
+
from .geom import to_point
|
|
411
|
+
pts = [to_point(p) for p in points]
|
|
412
|
+
if not pts:
|
|
413
|
+
return ""
|
|
414
|
+
parts = [f"M{fmt(pts[0].x)} {fmt(pts[0].y)}"]
|
|
415
|
+
parts += [f"L{fmt(p.x)} {fmt(p.y)}" for p in pts[1:]]
|
|
416
|
+
if close:
|
|
417
|
+
parts.append("Z")
|
|
418
|
+
return "".join(parts)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def rounded_polyline(points, radius: float = 0.0, close: bool = False) -> str:
|
|
422
|
+
"""Polyline with rounded corners — the workhorse for elbow connectors."""
|
|
423
|
+
from .geom import to_point
|
|
424
|
+
pts = [to_point(p) for p in points]
|
|
425
|
+
# drop consecutive duplicates
|
|
426
|
+
clean = [pts[0]] if pts else []
|
|
427
|
+
for p in pts[1:]:
|
|
428
|
+
if abs(p.x - clean[-1].x) > 1e-9 or abs(p.y - clean[-1].y) > 1e-9:
|
|
429
|
+
clean.append(p)
|
|
430
|
+
pts = clean
|
|
431
|
+
if len(pts) < 2:
|
|
432
|
+
return path_from_points(pts, close)
|
|
433
|
+
if radius <= 0:
|
|
434
|
+
return path_from_points(pts, close)
|
|
435
|
+
|
|
436
|
+
parts = []
|
|
437
|
+
n = len(pts)
|
|
438
|
+
idxs = range(n) if close else range(1, n - 1)
|
|
439
|
+
if not close:
|
|
440
|
+
parts.append(f"M{fmt(pts[0].x)} {fmt(pts[0].y)}")
|
|
441
|
+
else:
|
|
442
|
+
first_prev, first_cur = pts[-1], pts[0]
|
|
443
|
+
v_in = (first_cur - first_prev)
|
|
444
|
+
start = first_cur - v_in.normalized() * min(radius, v_in.length / 2)
|
|
445
|
+
parts.append(f"M{fmt(start.x)} {fmt(start.y)}")
|
|
446
|
+
|
|
447
|
+
for i in idxs:
|
|
448
|
+
prev_p = pts[(i - 1) % n]
|
|
449
|
+
cur = pts[i]
|
|
450
|
+
nxt = pts[(i + 1) % n]
|
|
451
|
+
v_in = cur - prev_p
|
|
452
|
+
v_out = nxt - cur
|
|
453
|
+
len_in, len_out = v_in.length, v_out.length
|
|
454
|
+
if len_in < 1e-9 or len_out < 1e-9:
|
|
455
|
+
continue
|
|
456
|
+
u_in, u_out = v_in.normalized(), v_out.normalized()
|
|
457
|
+
# collinear corner: nothing to round
|
|
458
|
+
if abs(u_in.x * u_out.y - u_in.y * u_out.x) < 1e-6 and \
|
|
459
|
+
(u_in.x * u_out.x + u_in.y * u_out.y) > 0:
|
|
460
|
+
continue
|
|
461
|
+
r = min(radius, len_in / 2.0, len_out / 2.0)
|
|
462
|
+
a = cur - v_in.normalized() * r
|
|
463
|
+
b = cur + v_out.normalized() * r
|
|
464
|
+
parts.append(f"L{fmt(a.x)} {fmt(a.y)}")
|
|
465
|
+
parts.append(f"Q{fmt(cur.x)} {fmt(cur.y)} {fmt(b.x)} {fmt(b.y)}")
|
|
466
|
+
if close:
|
|
467
|
+
parts.append("Z")
|
|
468
|
+
else:
|
|
469
|
+
parts.append(f"L{fmt(pts[-1].x)} {fmt(pts[-1].y)}")
|
|
470
|
+
return "".join(parts)
|