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/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()