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/geom.py
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
"""Geometry primitives: points, boxes, affine transforms."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Iterable, Sequence
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Point",
|
|
11
|
+
"BBox",
|
|
12
|
+
"Affine",
|
|
13
|
+
"ANCHOR_NAMES",
|
|
14
|
+
"to_point",
|
|
15
|
+
"deg2rad",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
ANCHOR_NAMES = (
|
|
19
|
+
"nw", "n", "ne",
|
|
20
|
+
"w", "center", "e",
|
|
21
|
+
"sw", "s", "se",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# Fractional (u, v) position inside a bbox for each named anchor.
|
|
25
|
+
_ANCHOR_UV = {
|
|
26
|
+
"nw": (0.0, 0.0), "n": (0.5, 0.0), "ne": (1.0, 0.0),
|
|
27
|
+
"w": (0.0, 0.5), "center": (0.5, 0.5), "c": (0.5, 0.5), "e": (1.0, 0.5),
|
|
28
|
+
"sw": (0.0, 1.0), "s": (0.5, 1.0), "se": (1.0, 1.0),
|
|
29
|
+
# friendly aliases
|
|
30
|
+
"top": (0.5, 0.0), "bottom": (0.5, 1.0),
|
|
31
|
+
"left": (0.0, 0.5), "right": (1.0, 0.5),
|
|
32
|
+
"topleft": (0.0, 0.0), "topright": (1.0, 0.0),
|
|
33
|
+
"bottomleft": (0.0, 1.0), "bottomright": (1.0, 1.0),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
_OPPOSITE = {
|
|
37
|
+
"n": "s", "s": "n", "e": "w", "w": "e",
|
|
38
|
+
"ne": "sw", "sw": "ne", "nw": "se", "se": "nw",
|
|
39
|
+
"center": "center",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# Outward unit normal for each side anchor (SVG axes: +y is down).
|
|
43
|
+
_ANCHOR_NORMAL = {
|
|
44
|
+
"n": (0.0, -1.0), "s": (0.0, 1.0), "e": (1.0, 0.0), "w": (-1.0, 0.0),
|
|
45
|
+
"ne": (0.7071, -0.7071), "nw": (-0.7071, -0.7071),
|
|
46
|
+
"se": (0.7071, 0.7071), "sw": (-0.7071, 0.7071),
|
|
47
|
+
"center": (0.0, 0.0),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def deg2rad(deg: float) -> float:
|
|
52
|
+
return deg * math.pi / 180.0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class Point:
|
|
57
|
+
"""An immutable 2D point. Supports +, -, * and tuple unpacking."""
|
|
58
|
+
|
|
59
|
+
x: float
|
|
60
|
+
y: float
|
|
61
|
+
|
|
62
|
+
# -- construction ---------------------------------------------------
|
|
63
|
+
@staticmethod
|
|
64
|
+
def of(obj) -> "Point":
|
|
65
|
+
return to_point(obj)
|
|
66
|
+
|
|
67
|
+
# -- arithmetic -----------------------------------------------------
|
|
68
|
+
def __add__(self, other) -> "Point":
|
|
69
|
+
if isinstance(other, (int, float)):
|
|
70
|
+
return Point(self.x + other, self.y + other)
|
|
71
|
+
o = to_point(other)
|
|
72
|
+
return Point(self.x + o.x, self.y + o.y)
|
|
73
|
+
|
|
74
|
+
__radd__ = __add__
|
|
75
|
+
|
|
76
|
+
def __sub__(self, other) -> "Point":
|
|
77
|
+
if isinstance(other, (int, float)):
|
|
78
|
+
return Point(self.x - other, self.y - other)
|
|
79
|
+
o = to_point(other)
|
|
80
|
+
return Point(self.x - o.x, self.y - o.y)
|
|
81
|
+
|
|
82
|
+
def __rsub__(self, other) -> "Point":
|
|
83
|
+
o = to_point(other)
|
|
84
|
+
return Point(o.x - self.x, o.y - self.y)
|
|
85
|
+
|
|
86
|
+
def __mul__(self, k: float) -> "Point":
|
|
87
|
+
return Point(self.x * k, self.y * k)
|
|
88
|
+
|
|
89
|
+
__rmul__ = __mul__
|
|
90
|
+
|
|
91
|
+
def __truediv__(self, k: float) -> "Point":
|
|
92
|
+
return Point(self.x / k, self.y / k)
|
|
93
|
+
|
|
94
|
+
def __neg__(self) -> "Point":
|
|
95
|
+
return Point(-self.x, -self.y)
|
|
96
|
+
|
|
97
|
+
def __iter__(self):
|
|
98
|
+
yield self.x
|
|
99
|
+
yield self.y
|
|
100
|
+
|
|
101
|
+
def __getitem__(self, i: int) -> float:
|
|
102
|
+
return (self.x, self.y)[i]
|
|
103
|
+
|
|
104
|
+
def __len__(self) -> int:
|
|
105
|
+
return 2
|
|
106
|
+
|
|
107
|
+
# -- vector helpers -------------------------------------------------
|
|
108
|
+
@property
|
|
109
|
+
def length(self) -> float:
|
|
110
|
+
return math.hypot(self.x, self.y)
|
|
111
|
+
|
|
112
|
+
def normalized(self) -> "Point":
|
|
113
|
+
n = self.length
|
|
114
|
+
return Point(0.0, 0.0) if n == 0 else Point(self.x / n, self.y / n)
|
|
115
|
+
|
|
116
|
+
def dot(self, other) -> float:
|
|
117
|
+
o = to_point(other)
|
|
118
|
+
return self.x * o.x + self.y * o.y
|
|
119
|
+
|
|
120
|
+
def perp(self) -> "Point":
|
|
121
|
+
"""Rotate 90 degrees counter-clockwise on screen."""
|
|
122
|
+
return Point(self.y, -self.x)
|
|
123
|
+
|
|
124
|
+
def rotated(self, deg: float, about=(0.0, 0.0)) -> "Point":
|
|
125
|
+
c, s = math.cos(deg2rad(deg)), math.sin(deg2rad(deg))
|
|
126
|
+
o = to_point(about)
|
|
127
|
+
dx, dy = self.x - o.x, self.y - o.y
|
|
128
|
+
return Point(o.x + dx * c - dy * s, o.y + dx * s + dy * c)
|
|
129
|
+
|
|
130
|
+
def angle_to(self, other) -> float:
|
|
131
|
+
o = to_point(other)
|
|
132
|
+
return math.degrees(math.atan2(o.y - self.y, o.x - self.x))
|
|
133
|
+
|
|
134
|
+
def distance_to(self, other) -> float:
|
|
135
|
+
o = to_point(other)
|
|
136
|
+
return math.hypot(o.x - self.x, o.y - self.y)
|
|
137
|
+
|
|
138
|
+
def lerp(self, other, t: float) -> "Point":
|
|
139
|
+
o = to_point(other)
|
|
140
|
+
return Point(self.x + (o.x - self.x) * t, self.y + (o.y - self.y) * t)
|
|
141
|
+
|
|
142
|
+
def offset(self, dx: float = 0.0, dy: float = 0.0) -> "Point":
|
|
143
|
+
return Point(self.x + dx, self.y + dy)
|
|
144
|
+
|
|
145
|
+
# -- misc -----------------------------------------------------------
|
|
146
|
+
@property
|
|
147
|
+
def point(self) -> "Point":
|
|
148
|
+
"""Anchor protocol: a Point resolves to itself."""
|
|
149
|
+
return self
|
|
150
|
+
|
|
151
|
+
def __eq__(self, other) -> bool:
|
|
152
|
+
if isinstance(other, Point):
|
|
153
|
+
return self.x == other.x and self.y == other.y
|
|
154
|
+
if isinstance(other, (tuple, list)) and len(other) == 2:
|
|
155
|
+
return self.x == other[0] and self.y == other[1]
|
|
156
|
+
return NotImplemented
|
|
157
|
+
|
|
158
|
+
def __hash__(self) -> int:
|
|
159
|
+
return hash((self.x, self.y))
|
|
160
|
+
|
|
161
|
+
def __repr__(self) -> str:
|
|
162
|
+
return f"Point({self.x:.4g}, {self.y:.4g})"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def to_point(obj) -> Point:
|
|
166
|
+
"""Coerce anything point-like into a :class:`Point`.
|
|
167
|
+
|
|
168
|
+
Accepts ``Point``, ``(x, y)`` tuples/lists, anything exposing a ``.point``
|
|
169
|
+
property (anchors, elements) or a ``.bbox`` (elements -> centre).
|
|
170
|
+
"""
|
|
171
|
+
if isinstance(obj, Point):
|
|
172
|
+
return obj
|
|
173
|
+
if isinstance(obj, (tuple, list)) and len(obj) == 2:
|
|
174
|
+
return Point(float(obj[0]), float(obj[1]))
|
|
175
|
+
p = getattr(obj, "point", None)
|
|
176
|
+
if p is not None:
|
|
177
|
+
return p if isinstance(p, Point) else to_point(p)
|
|
178
|
+
bb = getattr(obj, "bbox", None)
|
|
179
|
+
if bb is not None:
|
|
180
|
+
return bb.center
|
|
181
|
+
raise TypeError(f"cannot interpret {obj!r} as a point")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass(frozen=True)
|
|
185
|
+
class BBox:
|
|
186
|
+
"""Axis-aligned bounding box, ``(x, y)`` = top-left corner."""
|
|
187
|
+
|
|
188
|
+
x: float
|
|
189
|
+
y: float
|
|
190
|
+
w: float
|
|
191
|
+
h: float
|
|
192
|
+
|
|
193
|
+
# -- construction ---------------------------------------------------
|
|
194
|
+
@staticmethod
|
|
195
|
+
def from_corners(x0: float, y0: float, x1: float, y1: float) -> "BBox":
|
|
196
|
+
return BBox(min(x0, x1), min(y0, y1), abs(x1 - x0), abs(y1 - y0))
|
|
197
|
+
|
|
198
|
+
@staticmethod
|
|
199
|
+
def from_points(points: Iterable) -> "BBox":
|
|
200
|
+
pts = [to_point(p) for p in points]
|
|
201
|
+
if not pts:
|
|
202
|
+
return BBox(0.0, 0.0, 0.0, 0.0)
|
|
203
|
+
xs = [p.x for p in pts]
|
|
204
|
+
ys = [p.y for p in pts]
|
|
205
|
+
return BBox.from_corners(min(xs), min(ys), max(xs), max(ys))
|
|
206
|
+
|
|
207
|
+
@staticmethod
|
|
208
|
+
def union_all(boxes: Iterable["BBox"]) -> "BBox | None":
|
|
209
|
+
out = None
|
|
210
|
+
for b in boxes:
|
|
211
|
+
if b is None:
|
|
212
|
+
continue
|
|
213
|
+
out = b if out is None else out.union(b)
|
|
214
|
+
return out
|
|
215
|
+
|
|
216
|
+
# -- edges / corners ------------------------------------------------
|
|
217
|
+
@property
|
|
218
|
+
def x0(self) -> float:
|
|
219
|
+
return self.x
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def y0(self) -> float:
|
|
223
|
+
return self.y
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def x1(self) -> float:
|
|
227
|
+
return self.x + self.w
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
def y1(self) -> float:
|
|
231
|
+
return self.y + self.h
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def left(self) -> float:
|
|
235
|
+
return self.x
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def right(self) -> float:
|
|
239
|
+
return self.x + self.w
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def top(self) -> float:
|
|
243
|
+
return self.y
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def bottom(self) -> float:
|
|
247
|
+
return self.y + self.h
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def cx(self) -> float:
|
|
251
|
+
return self.x + self.w / 2.0
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def cy(self) -> float:
|
|
255
|
+
return self.y + self.h / 2.0
|
|
256
|
+
|
|
257
|
+
@property
|
|
258
|
+
def center(self) -> Point:
|
|
259
|
+
return Point(self.cx, self.cy)
|
|
260
|
+
|
|
261
|
+
@property
|
|
262
|
+
def size(self) -> Point:
|
|
263
|
+
return Point(self.w, self.h)
|
|
264
|
+
|
|
265
|
+
@property
|
|
266
|
+
def corners(self) -> tuple:
|
|
267
|
+
return (
|
|
268
|
+
Point(self.x0, self.y0),
|
|
269
|
+
Point(self.x1, self.y0),
|
|
270
|
+
Point(self.x1, self.y1),
|
|
271
|
+
Point(self.x0, self.y1),
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
# -- lookups --------------------------------------------------------
|
|
275
|
+
def anchor(self, name: str) -> Point:
|
|
276
|
+
key = str(name).lower().replace("_", "").replace("-", "")
|
|
277
|
+
if key not in _ANCHOR_UV:
|
|
278
|
+
raise KeyError(f"unknown anchor {name!r}; try one of {ANCHOR_NAMES}")
|
|
279
|
+
u, v = _ANCHOR_UV[key]
|
|
280
|
+
return Point(self.x + u * self.w, self.y + v * self.h)
|
|
281
|
+
|
|
282
|
+
def uv(self, u: float, v: float) -> Point:
|
|
283
|
+
"""Point at fractional position ``(u, v)`` inside the box."""
|
|
284
|
+
return Point(self.x + u * self.w, self.y + v * self.h)
|
|
285
|
+
|
|
286
|
+
def at_angle(self, deg: float) -> Point:
|
|
287
|
+
"""Point on the box border along a ray from the centre.
|
|
288
|
+
|
|
289
|
+
``0`` points right (east), angles increase clockwise on screen.
|
|
290
|
+
"""
|
|
291
|
+
a = deg2rad(deg)
|
|
292
|
+
dx, dy = math.cos(a), math.sin(a)
|
|
293
|
+
if self.w <= 0 or self.h <= 0:
|
|
294
|
+
return self.center
|
|
295
|
+
# Scale the ray so it lands exactly on the border of the rectangle.
|
|
296
|
+
tx = float("inf") if dx == 0 else (self.w / 2.0) / abs(dx)
|
|
297
|
+
ty = float("inf") if dy == 0 else (self.h / 2.0) / abs(dy)
|
|
298
|
+
t = min(tx, ty)
|
|
299
|
+
return Point(self.cx + dx * t, self.cy + dy * t)
|
|
300
|
+
|
|
301
|
+
# -- combinators ----------------------------------------------------
|
|
302
|
+
def union(self, other: "BBox") -> "BBox":
|
|
303
|
+
return BBox.from_corners(
|
|
304
|
+
min(self.x0, other.x0), min(self.y0, other.y0),
|
|
305
|
+
max(self.x1, other.x1), max(self.y1, other.y1),
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def intersection(self, other: "BBox") -> "BBox | None":
|
|
309
|
+
x0 = max(self.x0, other.x0)
|
|
310
|
+
y0 = max(self.y0, other.y0)
|
|
311
|
+
x1 = min(self.x1, other.x1)
|
|
312
|
+
y1 = min(self.y1, other.y1)
|
|
313
|
+
if x1 <= x0 or y1 <= y0:
|
|
314
|
+
return None
|
|
315
|
+
return BBox.from_corners(x0, y0, x1, y1)
|
|
316
|
+
|
|
317
|
+
def expand(self, pad=0.0, top=None, right=None, bottom=None, left=None) -> "BBox":
|
|
318
|
+
"""Grow the box outward. ``pad`` may be a scalar or a 2/4-tuple."""
|
|
319
|
+
t, r, b, l = _expand_spec(pad)
|
|
320
|
+
if top is not None:
|
|
321
|
+
t = top
|
|
322
|
+
if right is not None:
|
|
323
|
+
r = right
|
|
324
|
+
if bottom is not None:
|
|
325
|
+
b = bottom
|
|
326
|
+
if left is not None:
|
|
327
|
+
l = left
|
|
328
|
+
return BBox.from_corners(self.x0 - l, self.y0 - t, self.x1 + r, self.y1 + b)
|
|
329
|
+
|
|
330
|
+
def shrink(self, pad=0.0, **kw) -> "BBox":
|
|
331
|
+
t, r, b, l = _expand_spec(pad)
|
|
332
|
+
return self.expand(0.0, top=-t, right=-r, bottom=-b, left=-l)
|
|
333
|
+
|
|
334
|
+
def translated(self, dx: float, dy: float) -> "BBox":
|
|
335
|
+
return BBox(self.x + dx, self.y + dy, self.w, self.h)
|
|
336
|
+
|
|
337
|
+
def contains(self, p) -> bool:
|
|
338
|
+
q = to_point(p)
|
|
339
|
+
return self.x0 <= q.x <= self.x1 and self.y0 <= q.y <= self.y1
|
|
340
|
+
|
|
341
|
+
def overlaps(self, other: "BBox") -> bool:
|
|
342
|
+
return self.intersection(other) is not None
|
|
343
|
+
|
|
344
|
+
def __eq__(self, other) -> bool:
|
|
345
|
+
if isinstance(other, BBox):
|
|
346
|
+
return (self.x, self.y, self.w, self.h) == \
|
|
347
|
+
(other.x, other.y, other.w, other.h)
|
|
348
|
+
if isinstance(other, (tuple, list)) and len(other) == 4:
|
|
349
|
+
return (self.x, self.y, self.w, self.h) == tuple(other)
|
|
350
|
+
return NotImplemented
|
|
351
|
+
|
|
352
|
+
def __hash__(self) -> int:
|
|
353
|
+
return hash((self.x, self.y, self.w, self.h))
|
|
354
|
+
|
|
355
|
+
def __repr__(self) -> str:
|
|
356
|
+
return f"BBox(x={self.x:.4g}, y={self.y:.4g}, w={self.w:.4g}, h={self.h:.4g})"
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _expand_spec(pad) -> tuple:
|
|
360
|
+
"""Normalise a CSS-ish padding spec into ``(top, right, bottom, left)``."""
|
|
361
|
+
if pad is None:
|
|
362
|
+
return (0.0, 0.0, 0.0, 0.0)
|
|
363
|
+
if isinstance(pad, (int, float)):
|
|
364
|
+
return (float(pad),) * 4
|
|
365
|
+
vals = list(pad)
|
|
366
|
+
if len(vals) == 1:
|
|
367
|
+
return (float(vals[0]),) * 4
|
|
368
|
+
if len(vals) == 2:
|
|
369
|
+
v, h = float(vals[0]), float(vals[1])
|
|
370
|
+
return (v, h, v, h)
|
|
371
|
+
if len(vals) == 3:
|
|
372
|
+
t, h, b = map(float, vals)
|
|
373
|
+
return (t, h, b, h)
|
|
374
|
+
if len(vals) == 4:
|
|
375
|
+
return tuple(map(float, vals))
|
|
376
|
+
raise ValueError(f"bad padding spec: {pad!r}")
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@dataclass(frozen=True)
|
|
380
|
+
class Affine:
|
|
381
|
+
"""2D affine transform ``[[a c e], [b d f]]`` matching the SVG matrix()."""
|
|
382
|
+
|
|
383
|
+
a: float = 1.0
|
|
384
|
+
b: float = 0.0
|
|
385
|
+
c: float = 0.0
|
|
386
|
+
d: float = 1.0
|
|
387
|
+
e: float = 0.0
|
|
388
|
+
f: float = 0.0
|
|
389
|
+
|
|
390
|
+
IDENTITY: "Affine" = None # filled in below
|
|
391
|
+
|
|
392
|
+
@staticmethod
|
|
393
|
+
def translate(dx: float, dy: float = 0.0) -> "Affine":
|
|
394
|
+
return Affine(1, 0, 0, 1, dx, dy)
|
|
395
|
+
|
|
396
|
+
@staticmethod
|
|
397
|
+
def scale(sx: float, sy: float = None) -> "Affine":
|
|
398
|
+
sy = sx if sy is None else sy
|
|
399
|
+
return Affine(sx, 0, 0, sy, 0, 0)
|
|
400
|
+
|
|
401
|
+
@staticmethod
|
|
402
|
+
def rotate(deg: float, about=(0.0, 0.0)) -> "Affine":
|
|
403
|
+
o = to_point(about)
|
|
404
|
+
r = deg2rad(deg)
|
|
405
|
+
cs, sn = math.cos(r), math.sin(r)
|
|
406
|
+
m = Affine(cs, sn, -sn, cs, 0, 0)
|
|
407
|
+
return Affine.translate(o.x, o.y) @ m @ Affine.translate(-o.x, -o.y)
|
|
408
|
+
|
|
409
|
+
@staticmethod
|
|
410
|
+
def skew(deg_x: float = 0.0, deg_y: float = 0.0) -> "Affine":
|
|
411
|
+
return Affine(1, math.tan(deg2rad(deg_y)), math.tan(deg2rad(deg_x)), 1, 0, 0)
|
|
412
|
+
|
|
413
|
+
def __matmul__(self, other: "Affine") -> "Affine":
|
|
414
|
+
"""``self @ other`` applies *other* first, then *self*."""
|
|
415
|
+
return Affine(
|
|
416
|
+
self.a * other.a + self.c * other.b,
|
|
417
|
+
self.b * other.a + self.d * other.b,
|
|
418
|
+
self.a * other.c + self.c * other.d,
|
|
419
|
+
self.b * other.c + self.d * other.d,
|
|
420
|
+
self.a * other.e + self.c * other.f + self.e,
|
|
421
|
+
self.b * other.e + self.d * other.f + self.f,
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
def apply(self, p) -> Point:
|
|
425
|
+
q = to_point(p)
|
|
426
|
+
return Point(self.a * q.x + self.c * q.y + self.e,
|
|
427
|
+
self.b * q.x + self.d * q.y + self.f)
|
|
428
|
+
|
|
429
|
+
def apply_bbox(self, bb: BBox) -> BBox:
|
|
430
|
+
return BBox.from_points(self.apply(c) for c in bb.corners)
|
|
431
|
+
|
|
432
|
+
@property
|
|
433
|
+
def is_identity(self) -> bool:
|
|
434
|
+
return (self.a, self.b, self.c, self.d, self.e, self.f) == (1, 0, 0, 1, 0, 0)
|
|
435
|
+
|
|
436
|
+
def inverse(self) -> "Affine":
|
|
437
|
+
det = self.a * self.d - self.b * self.c
|
|
438
|
+
if abs(det) < 1e-12:
|
|
439
|
+
raise ZeroDivisionError("affine transform is not invertible")
|
|
440
|
+
ia, ib = self.d / det, -self.b / det
|
|
441
|
+
ic, idd = -self.c / det, self.a / det
|
|
442
|
+
ie = -(ia * self.e + ic * self.f)
|
|
443
|
+
if_ = -(ib * self.e + idd * self.f)
|
|
444
|
+
return Affine(ia, ib, ic, idd, ie, if_)
|
|
445
|
+
|
|
446
|
+
def to_svg(self) -> str:
|
|
447
|
+
n = _num
|
|
448
|
+
return (f"matrix({n(self.a)},{n(self.b)},{n(self.c)},"
|
|
449
|
+
f"{n(self.d)},{n(self.e)},{n(self.f)})")
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
Affine.IDENTITY = Affine()
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _num(v: float) -> str:
|
|
456
|
+
"""Compact float formatting for SVG output."""
|
|
457
|
+
if v is None:
|
|
458
|
+
return "0"
|
|
459
|
+
if isinstance(v, str):
|
|
460
|
+
return v
|
|
461
|
+
if v == int(v) and abs(v) < 1e15:
|
|
462
|
+
return str(int(v))
|
|
463
|
+
return f"{v:.4f}".rstrip("0").rstrip(".")
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def opposite_anchor(name: str) -> str:
|
|
467
|
+
return _OPPOSITE.get(str(name).lower(), "center")
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def anchor_normal(name: str) -> Point:
|
|
471
|
+
nx, ny = _ANCHOR_NORMAL.get(str(name).lower(), (0.0, 0.0))
|
|
472
|
+
return Point(nx, ny)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def polyline_length(pts: Sequence) -> float:
|
|
476
|
+
pts = [to_point(p) for p in pts]
|
|
477
|
+
return sum(pts[i].distance_to(pts[i + 1]) for i in range(len(pts) - 1))
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def point_along(pts: Sequence, t: float) -> tuple:
|
|
481
|
+
"""Point (and unit direction) at fraction ``t`` along a polyline."""
|
|
482
|
+
pts = [to_point(p) for p in pts]
|
|
483
|
+
if len(pts) < 2:
|
|
484
|
+
return (pts[0] if pts else Point(0, 0)), Point(1, 0)
|
|
485
|
+
total = polyline_length(pts)
|
|
486
|
+
if total == 0:
|
|
487
|
+
return pts[0], Point(1, 0)
|
|
488
|
+
target = max(0.0, min(1.0, t)) * total
|
|
489
|
+
acc = 0.0
|
|
490
|
+
for i in range(len(pts) - 1):
|
|
491
|
+
seg = pts[i].distance_to(pts[i + 1])
|
|
492
|
+
if acc + seg >= target or i == len(pts) - 2:
|
|
493
|
+
local = 0.0 if seg == 0 else (target - acc) / seg
|
|
494
|
+
local = max(0.0, min(1.0, local))
|
|
495
|
+
return pts[i].lerp(pts[i + 1], local), (pts[i + 1] - pts[i]).normalized()
|
|
496
|
+
acc += seg
|
|
497
|
+
return pts[-1], (pts[-1] - pts[-2]).normalized()
|