py-geometry-utils 0.5.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.
geometry/__init__.py ADDED
@@ -0,0 +1,779 @@
1
+ """Utilities for working with coordinates and rectangles/grids."""
2
+ import math
3
+ import operator
4
+ from collections.abc import Callable, Generator
5
+ from copy import copy
6
+ from itertools import product
7
+ from typing import Literal, Self, cast, overload, override
8
+
9
+ from geometry.util import snap_num
10
+
11
+ __version__ = '0.5.0'
12
+
13
+ type Tuple2[T] = tuple[T, T]
14
+ """A tuple of 2 values of the same type."""
15
+ type CoordOrTuple2 = Coord2 | tuple[float, float]
16
+ """A :class:`Coord2` instance or a tuple of two ``float`` types as X and Y coordinates."""
17
+ type Tuple4[T] = tuple[T, T, T, T]
18
+ """A tuple of 4 values of the same type."""
19
+ type RectOrTuple = Rect | tuple[float, float, float, float]
20
+ """A :class:`Rect` instance or a tuple of four ``float`` types as X1, Y1, X2, and Y2 coordinates."""
21
+
22
+ class Coord2:
23
+ """Represents a 2D coordinate.
24
+
25
+ Properties :data:`x` and :data:`y` are immutable by default; initialize the instance with ``mut=True`` to allow
26
+ setting their values.
27
+ """
28
+
29
+ def __init__(self, x: float, y: float, *, mut: bool = False) -> None:
30
+ """
31
+ :param mut: Whether this instance's attributes can be modified or not. If ``False``, ``TypeError`` is raised
32
+ when attempting to reassign them.
33
+ """ # noqa: D205, D212
34
+ if not isinstance(x, int | float):
35
+ raise TypeError(f"{self.__class__.__name__}.__init__() parameter 'x' must be 'int' or 'float': {x!r}")
36
+ if not isinstance(y, int | float):
37
+ raise TypeError(f"{self.__class__.__name__}.__init__() parameter 'y' must be 'int' or 'float': {y!r}")
38
+
39
+ self._mut = mut
40
+
41
+ self._x = x
42
+ self._y = y
43
+
44
+ @property
45
+ def x(self) -> float: # testcheck: ignore
46
+ """X coordinate."""
47
+ return self._x
48
+
49
+ @x.setter
50
+ def x(self, value: float) -> None:
51
+ if not self._mut:
52
+ raise TypeError(f'Cannot modify attribute of immutable {self.__class__.__name__} instance')
53
+ self._x = value
54
+
55
+ @property
56
+ def y(self) -> float: # testcheck: ignore
57
+ """Y coordinate."""
58
+ return self._y
59
+
60
+ @y.setter
61
+ def y(self, value: float) -> None:
62
+ if not self._mut:
63
+ raise TypeError(f'Cannot modify attribute of immutable {self.__class__.__name__} instance')
64
+ self._y = value
65
+
66
+ @property
67
+ def mutable(self) -> bool:
68
+ """Whether this instance's attributes can be modified."""
69
+ return self._mut
70
+
71
+ def __repr__(self) -> str: # noqa: D105
72
+ return f'{self.__class__.__name__}(x={self.x}, y={self.y})'
73
+
74
+ def __str__(self) -> str:
75
+ """Returns this coordinate in the format ``(x, y)``."""
76
+ return f'({self.x}, {self.y})'
77
+
78
+ def __iter__(self) -> Generator[float]:
79
+ """Yields the X and Y values of this coordinate."""
80
+ yield from self.as_tuple()
81
+
82
+ def __getitem__(self, idx: int) -> float:
83
+ """Returns the item at ``idx`` from a tuple of this coordinate's values."""
84
+ return self.as_tuple()[idx]
85
+
86
+ def __hash__(self) -> int:
87
+ """Returns the hash of a tuple of this coordinate's values."""
88
+ return hash(self.as_tuple())
89
+
90
+ def __copy__(self) -> Self:
91
+ """Returns a new instance with the same values as this instance."""
92
+ return self.__class__(self.x, self.y, mut=self.mutable)
93
+
94
+ def __eq__(self, other: object) -> bool:
95
+ """Compares the X and Y values of two coordinates, returns ``False`` for other objects."""
96
+ if isinstance(other, tuple):
97
+ return self.as_tuple() == other
98
+ if isinstance(other, self.__class__):
99
+ return self.as_tuple() == other.as_tuple()
100
+
101
+ return False
102
+
103
+ def _compare(self, op: Callable[[Tuple2[float], Tuple2[float]], bool], other: Self | Tuple2[float]) -> bool:
104
+ if isinstance(other, tuple):
105
+ return op(self.as_tuple(), cast('Tuple2[float]', other))
106
+ if isinstance(other, Coord2):
107
+ return op(self.as_tuple(), other.as_tuple())
108
+
109
+ return NotImplemented
110
+
111
+ def __ge__(self, other: Self | tuple[float, float]) -> bool:
112
+ """Returns ``True`` if at least one coordinate value is greater than or equal to the other, else ``False``."""
113
+ return self._compare(operator.ge, other)
114
+
115
+ def __gt__(self, other: Self | tuple[float, float]) -> bool:
116
+ """Returns ``True`` if at least one coordinate value is greater than the other, else ``False``."""
117
+ return self._compare(operator.gt, other)
118
+
119
+ def __le__(self, other: Self | tuple[float, float]) -> bool:
120
+ """Returns ``True`` if at least one coordinate value is less than or equal to the other, else ``False``."""
121
+ return self._compare(operator.le, other)
122
+
123
+ def __lt__(self, other: Self | tuple[float, float]) -> bool:
124
+ """Returns ``True`` if at least one coordinate value is less than the other, else ``False``."""
125
+ return self._compare(operator.lt, other)
126
+
127
+ def __add__(self, other: Self | tuple[float, float] | float) -> Self:
128
+ """Returns a new coordinate with this and another coordinate's X and Y values added together.
129
+
130
+ If given a single value, it is added to the X and Y values of this coordinate.
131
+
132
+ >>> assert Coord2(1, 2) + Coord2(1, 2) == Coord2(2, 4)
133
+ >>> assert Coord2(1, 2) + (1, 2) == Coord2(2, 4)
134
+ >>> assert Coord2(1, 2) + 1 == Coord2(2, 3)
135
+ """
136
+ return self.zip_with(operator.add, other)
137
+
138
+ def __sub__(self, other: Self | tuple[float, float] | float) -> Self:
139
+ """Returns a new coordinate with this and another coordinate's X and Y values subtracted from eachother.
140
+
141
+ If given a single value, it is subtracted from the X and Y values of this coordinate.
142
+
143
+ >>> assert Coord2(1, 2) - Coord2(1, 2) == Coord2(0, 0)
144
+ >>> assert Coord2(1, 2) - (1, 2) == Coord2(0, 0)
145
+ >>> assert Coord2(1, 2) - 1 == Coord2(0, 1)
146
+ """
147
+ return self.zip_with(operator.sub, other)
148
+
149
+ def __mul__(self, other: Self | tuple[float, float] | float) -> Self:
150
+ """Returns a new coordinate with this and another coordinate's X and Y multiplied together.
151
+
152
+ If given a single value, the X and Y values of this coordinate are multiplied by it.
153
+
154
+ >>> assert Coord2(1, 2) * Coord2(2, 4) == Coord2(2, 8)
155
+ >>> assert Coord2(1, 2) * (2, 4) == Coord2(2, 8)
156
+ >>> assert Coord2(1, 2) * 2 == Coord2(2, 4)
157
+ """
158
+ return self.zip_with(operator.mul, other)
159
+
160
+ def __truediv__(self, other: Self | tuple[float, float] | float) -> Self:
161
+ """Returns a new coordinate with this and another coordinate's X and Y divided by eachother.
162
+
163
+ If given a single value, the X and Y values of this coordinate are divided by it.
164
+
165
+ >>> assert Coord2(1, 2) / Coord2(2, 8) == Coord2(0.5, 0.25)
166
+ >>> assert Coord2(1, 2) / (2, 8) == Coord2(0.5, 0.25)
167
+ >>> assert Coord2(1, 2) / 2 == Coord2(0.5, 1.0)
168
+ """
169
+ other = (other, other) if isinstance(other, int | float) else other
170
+
171
+ return self.zip_with(operator.truediv, other)
172
+
173
+ def __floordiv__(self, other: Self | tuple[float, float] | float) -> Self:
174
+ """Returns a new coordinate with this and another coordinate's X and Y values divided by eachother and floored.
175
+
176
+ If given a single value, the X and Y values of this coordinate are divided by it and floored.
177
+
178
+ >>> assert Coord2(1, 2) // Coord2(2, 8) == Coord2(0, 0)
179
+ >>> assert Coord2(1, 2) // (2, 8) == Coord2(0, 0)
180
+ >>> assert Coord2(1, 2) // 2 == Coord2(0, 1)
181
+ """
182
+ return self.zip_with(operator.floordiv, other)
183
+
184
+ def __mod__(self, other: Self | tuple[float, float] | float) -> Self:
185
+ """Returns a new coordinate with this and another coordinate's X and Y values added together.
186
+
187
+ If given a single value, it is added to the X and Y values of this coordinate.
188
+
189
+ >>> assert Coord2(1, 2) % Coord2(2, 8) == Coord2(1, 2)
190
+ >>> assert Coord2(1, 2) % (2, 8) == Coord2(1, 2)
191
+ >>> assert Coord2(1, 2) % 2 == Coord2(1, 0)
192
+ """
193
+ return self.zip_with(operator.mod, other)
194
+
195
+ def __pow__(self, other: Self | tuple[float, float] | float) -> Self:
196
+ """Returns a new coordinate with this and another coordinate's X and Y values added together.
197
+
198
+ If given a single value, it is added to the X and Y values of this coordinate.
199
+
200
+ >>> assert Coord2(1, 2) ** Coord2(2, 4) == Coord2(1, 16)
201
+ >>> assert Coord2(1, 2) ** (2, 4) == Coord2(1, 16)
202
+ >>> assert Coord2(1, 2) ** 2 == Coord2(1, 4)
203
+ """
204
+ return self.zip_with(operator.pow, other)
205
+
206
+ @overload
207
+ def as_tuple(self, map_fn: None = None) -> tuple[float, float]: ...
208
+ @overload
209
+ def as_tuple[U](self, map_fn: Callable[[float], U]) -> tuple[U, U]: ...
210
+ def as_tuple[U](self, map_fn: Callable[[float], U] | None = None) -> tuple[object, object]:
211
+ """Returns the coordinate as a tuple, optionally mapping the values."""
212
+ if map_fn:
213
+ return (map_fn(self.x), map_fn(self.y))
214
+
215
+ return (self.x, self.y)
216
+
217
+ def distance(self, other: CoordOrTuple2, mode: Literal['euclid', 'taxi'] = 'taxi') -> float:
218
+ """Returns the euclidean or taxicab distance from this coordinate to ``other`` based on ``mode``.
219
+
220
+ :param mode: ``'euclid'`` will return the euclidean distance from ``self`` to ``other``, ``'taxi'`` returns the
221
+ taxicab distance.
222
+ """
223
+ match mode:
224
+ case 'euclid':
225
+ return math.sqrt(((self[0] - other[0]) ** 2) + ((self[1] - other[1]) ** 2))
226
+ case 'taxi':
227
+ return sum((self - other).as_tuple(abs))
228
+ case _:
229
+ raise ValueError(f'Unexpected mode: {mode!r}')
230
+
231
+ def format(self, s: str) -> str:
232
+ """Returns ``s`` formatted with this coordinate's ``x`` and ``y`` values."""
233
+ return s.format(x=self.x, y=self.y)
234
+
235
+ def in_bounds(self, rect: RectOrTuple, *, edge_ok: bool = True) -> bool:
236
+ """Returns whether this coordinate is within a rectangle's bounds, not counting the edge.
237
+
238
+ :param edge_ok: Whether the coordinate being on the rectangle's edge counts as in bounds or not.
239
+
240
+ >>> assert Coord2(1, 1).in_bounds((0, 0, 2, 2))
241
+ >>> assert Coord2(0, 0).in_bounds((0, 0, 2, 2))
242
+ >>> assert not Coord2(0, 0).in_bounds((0, 0, 2, 2), edge_ok=False)
243
+ >>> assert Coord2(2, 2).in_bounds((0, 0, 2, 2))
244
+ >>> assert not Coord2(2, 2).in_bounds((0, 0, 2, 2), edge_ok=False)
245
+ >>> assert not Coord2(3, 3).in_bounds((0, 0, 2, 2))
246
+ """
247
+ return (rect[0] <= self.x <= rect[2]) and (rect[1] <= self.y <= rect[3]) \
248
+ if edge_ok else (rect[0] < self.x < rect[2]) and (rect[1] < self.y < rect[3])
249
+
250
+ def map(self, fn: Callable[[float], float]) -> Self:
251
+ """Returns a new instance of this class with ``fn`` applied to its ``x`` and ``y`` attributes."""
252
+ return self.__class__(fn(self.x), fn(self.y))
253
+
254
+ def on_edge(self, rect: RectOrTuple) -> bool:
255
+ """Returns whether this coordinate sits on the edge of a rectangle."""
256
+ return ((rect[0] <= self.x <= rect[2]) and (self.y in (rect[1], rect[3]))) \
257
+ or ((rect[1] <= self.y <= rect[3]) and (self.x in (rect[0], rect[2])))
258
+
259
+ def snap_to_grid(self, grid: 'Grid2', snap_fn: Callable[[float], int] = round) -> Self:
260
+ """Returns a new instance whose X and Y values have been aligned to ``grid``.
261
+
262
+ Snapping is done based on ``grid``'s ``step`` and ``origin`` values. If either value of ``grid.step`` is 0,
263
+ that part of the coordinate is set to the corresponding value of the grid's origin, e.g. if ``grid.step.x`` is
264
+ 0, ``grid.origin.x`` is used for the ``x`` value of the returned instance.
265
+
266
+ :param snap_fn: Refer to :func:`geometry.util.snap_num`.
267
+ """
268
+ return self.__class__(
269
+ grid.origin.x if not grid.step.x \
270
+ else snap_num(self.x - grid.origin.x, grid.step.x, snap_fn) + grid.origin.x,
271
+ grid.origin.y if not grid.step.y \
272
+ else snap_num(self.y - grid.origin.y, grid.step.y, snap_fn) + grid.origin.y,
273
+ )
274
+
275
+ def zip_with(self, fn: Callable[[float, float], float], other: Self | tuple[float, float] | float) -> Self:
276
+ """Combines this and another instance or tuple's values using ``fn``.
277
+
278
+ The values used are those returned by iterating over the instance—for :class:`Coord2`, that would be ``x`` and
279
+ ``y``. If a single number is given for ``other``, it is used for both values.
280
+ """
281
+ if not isinstance(other, Coord2 | tuple):
282
+ other = (other, other)
283
+
284
+ return self.__class__(fn(self.x, other[0]), fn(self.y, other[1]))
285
+
286
+ class Rect:
287
+ """Represents a rectangle using its top-left and bottom-right coordinates.
288
+
289
+ Properties :data:`x1`, :data:`y1`, :data:`x2`, and :data:`y2` are immutable by default; initialize the instance with
290
+ ``mut=True`` to allow setting their values.
291
+ """
292
+
293
+ def __init__(self, x1: float, y1: float, x2: float, y2: float, *, mut: bool = False) -> None:
294
+ """
295
+ :param mut: Whether this instance's attributes can be modified or not. If ``False``, ``TypeError`` is raised
296
+ when attempting to reassign them.
297
+ """ # noqa: D205, D212
298
+ self._mut = mut
299
+
300
+ self._x1 = x1
301
+ self._y1 = y1
302
+ self._x2 = x2
303
+ self._y2 = y2
304
+
305
+ @property
306
+ def x1(self) -> float: # testcheck: ignore
307
+ """Top-left X coordinate."""
308
+ return self._x1
309
+
310
+ @x1.setter
311
+ def x1(self, value: float) -> None:
312
+ if not self._mut:
313
+ raise TypeError(f'Cannot modify attribute of immutable {self.__class__.__name__} instance')
314
+ self._x1 = value
315
+
316
+ @property
317
+ def y1(self) -> float: # testcheck: ignore
318
+ """Top-left Y coordinate."""
319
+ return self._y1
320
+
321
+ @y1.setter
322
+ def y1(self, value: float) -> None:
323
+ if not self._mut:
324
+ raise TypeError(f'Cannot modify attribute of immutable {self.__class__.__name__} instance')
325
+ self._y1 = value
326
+
327
+ @property
328
+ def x2(self) -> float: # testcheck: ignore
329
+ """Bottom-right X coordinate."""
330
+ return self._x2
331
+
332
+ @x2.setter
333
+ def x2(self, value: float) -> None:
334
+ if not self._mut:
335
+ raise TypeError(f'Cannot modify attribute of immutable {self.__class__.__name__} instance')
336
+ self._x2 = value
337
+
338
+ @property
339
+ def y2(self) -> float: # testcheck: ignore
340
+ """Bottom-right Y coordinate."""
341
+ return self._y2
342
+
343
+ @y2.setter
344
+ def y2(self, value: float) -> None:
345
+ if not self._mut:
346
+ raise TypeError(f'Cannot modify attribute of immutable {self.__class__.__name__} instance')
347
+ self._y2 = value
348
+
349
+ @property
350
+ def mutable(self) -> bool:
351
+ """Whether this instance's attributes can be modified."""
352
+ return self._mut
353
+
354
+ def __repr__(self) -> str: # noqa: D105
355
+ return f'{self.__class__.__name__}(x1={self.x1!r}, y1={self.y1!r}, x2={self.x2!r}, y2={self.y2!r})'
356
+
357
+ def __str__(self) -> str:
358
+ """Returns this rectangle in the format ``'(x1, y1, x2, y2)'``."""
359
+ return str(self.as_tuple())
360
+
361
+ def __iter__(self) -> Generator[float]:
362
+ """Returns a generator yielding from :meth:`as_tuple`."""
363
+ yield from self.as_tuple()
364
+
365
+ def __getitem__(self, idx: int) -> float:
366
+ """Returns the item at ``idx`` from :meth:`as_tuple`."""
367
+ return self.as_tuple()[idx]
368
+
369
+ def __hash__(self) -> int:
370
+ """Returns the hash of a :meth:`as_tuple`."""
371
+ return hash(self.as_tuple())
372
+
373
+ def __copy__(self) -> Self:
374
+ """Returns a new instance with the same values as this instance."""
375
+ return self.__class__(self.x1, self.y1, self.x2, self.y2, mut=self.mutable)
376
+
377
+ def __eq__(self, value: object) -> bool:
378
+ """Compares coordinate values if ``value`` is a tuple or ``Rect`` object, otherwise returns ``False``."""
379
+ if isinstance(value, tuple):
380
+ return self.as_tuple() == value
381
+ if isinstance(value, self.__class__):
382
+ return self.as_tuple() == value.as_tuple()
383
+
384
+ return False
385
+
386
+ # These are properties since the coordinate attributes could be changed, though I'm reconsidering
387
+ # whether Rects should be mutable at all
388
+
389
+ @property
390
+ def area(self) -> float:
391
+ """Total area of this rectangle."""
392
+ return self.width * self.height
393
+
394
+ @property
395
+ def bottom_left(self) -> Coord2:
396
+ """Bottom left corner coordinate."""
397
+ return Coord2(self.x1, self.y2)
398
+
399
+ @property
400
+ def bottom_right(self) -> Coord2:
401
+ """Bottom right corner coordinate."""
402
+ return Coord2(self.x2, self.y2)
403
+
404
+ @property
405
+ def center(self) -> Coord2:
406
+ """Center coordinate of this rectangle."""
407
+ return Coord2(self.x1 + (self.width / 2), self.y1 + (self.height / 2))
408
+
409
+ @property
410
+ def corners(self) -> tuple[Coord2, Coord2, Coord2, Coord2]:
411
+ """The four corner coordinates of this rectangle.
412
+
413
+ The order is top-left, top-right, bottom-left, bottom-right.
414
+ """
415
+ return (
416
+ self.top_left,
417
+ self.top_right,
418
+ self.bottom_left,
419
+ self.bottom_right,
420
+ )
421
+
422
+ @property
423
+ def height(self) -> float:
424
+ """Height of this rectangle."""
425
+ return self.y2 - self.y1
426
+
427
+ @property
428
+ def perimeter(self) -> float:
429
+ """Perimeter of this rectangle."""
430
+ return (self.width * 2) + (self.height * 2)
431
+
432
+ @property
433
+ def size(self) -> tuple[float, float]:
434
+ """A tuple of the width and height of this rectangle."""
435
+ return (self.width, self.height)
436
+
437
+ @property
438
+ def top_left(self) -> Coord2:
439
+ """Top left corner coordinate."""
440
+ return Coord2(self.x1, self.y1)
441
+
442
+ @property
443
+ def top_right(self) -> Coord2:
444
+ """Top right corner coordinate."""
445
+ return Coord2(self.x2, self.y1)
446
+
447
+ @property
448
+ def width(self) -> float:
449
+ """Width of this rectangle."""
450
+ return self.x2 - self.x1
451
+
452
+ @classmethod
453
+ def from_size(cls, size: CoordOrTuple2, center: CoordOrTuple2 | None = None) -> Self:
454
+ """Returns a new rectangle of the given size.
455
+
456
+ Created with its top left coordinate at ``0, 0`` by default unless ``center`` is specified, where it will be
457
+ sized out from that coordinate as the origin.
458
+ """
459
+ rad_x, rad_y = size[0] / 2, size[1] / 2
460
+ center_x, center_y = center if center is not None else (rad_x, rad_y)
461
+
462
+ return cls(
463
+ center_x - rad_x,
464
+ center_y - rad_y,
465
+ center_x + rad_x,
466
+ center_y + rad_y,
467
+ )
468
+
469
+ @overload
470
+ def as_tuple(self, map_fn: None = None) -> Tuple4[float]: ...
471
+ @overload
472
+ def as_tuple[U](self, map_fn: Callable[[float], U]) -> Tuple4[U]: ...
473
+ def as_tuple[U](self, map_fn: Callable[[float], U] | None = None) -> Tuple4[object]:
474
+ """Returns the X1, Y1, X2, and Y2 values as a tuple."""
475
+ if map_fn:
476
+ return (map_fn(self.x1), map_fn(self.y1), map_fn(self.x2), map_fn(self.y2))
477
+
478
+ return (self.x1, self.y1, self.x2, self.y2)
479
+
480
+ def map(self, fn: Callable[[float], float]) -> Self:
481
+ """Returns a new rectangle with ``fn`` applied to all coordinate values."""
482
+ return self.__class__(fn(self.x1), fn(self.y1), fn(self.x2), fn(self.y2))
483
+
484
+ def resize(self, xy: CoordOrTuple2, *, from_center: bool = False) -> Self:
485
+ """Returns a new rectangle of this instance's size added to by ``xy``.
486
+
487
+ By default, the rectangle is resized from the top-left corner, keeping its coordinate intact and only adding to
488
+ the bottom-right coordinate. If ``from_center`` is ``True``, it will be resized outward in all directions from
489
+ the center coordinate.
490
+ """
491
+ size_x, size_y = xy
492
+ if from_center:
493
+ size_x, size_y = size_x / 2, size_y / 2
494
+
495
+ return self.__class__(
496
+ self.x1 - (size_x if from_center else 0),
497
+ self.y1 - (size_y if from_center else 0),
498
+ self.x2 + size_x,
499
+ self.y2 + size_y,
500
+ )
501
+
502
+ def translate_by(self, xy: CoordOrTuple2) -> Self:
503
+ """Returns a new rectangle with this instance's coordinates shifted by ``xy``."""
504
+ tr_x, tr_y = xy
505
+
506
+ return self.__class__(self.x1 + tr_x, self.y1 + tr_y, self.x2 + tr_x, self.y2 + tr_y)
507
+
508
+ def translate_to(self, xy: CoordOrTuple2) -> Self:
509
+ """Returns a new rectangle with this instance's coordinates shifted such that its top left coordinate
510
+ equals ``xy``.
511
+ """ # noqa: D205
512
+ return self.translate_by(Coord2(*xy) - self.top_left)
513
+
514
+ def zip_with(self,
515
+ fn: Callable[[float, float], float],
516
+ other: Self | Tuple4[float] | float,
517
+ ) -> Self:
518
+ """Combines this and another instance or tuple's values using ``fn``.
519
+
520
+ The values used are those returned by iterating over the instance—for :class:`Rect`, that would be :data:`x1`,
521
+ :data:`y1`, :data:`x2`, and :data:`y2`.
522
+ """
523
+ if not isinstance(other, Rect | tuple):
524
+ other = (other, other, other, other)
525
+
526
+ return self.__class__(
527
+ fn(self.x1, other[0]),
528
+ fn(self.y1, other[1]),
529
+ fn(self.x2, other[2]),
530
+ fn(self.y2, other[3]),
531
+ )
532
+
533
+ class Grid2(Rect):
534
+ """Represents a 2D grid, with methods for iterating over its steps.
535
+
536
+ Subclass of :class:`Rect`.
537
+
538
+ Properties :data:`step` and :data:`origin` are immutable by default; initialize the instance with ``mut=True`` to
539
+ allow setting their values. When initialized as immutable, if :class:`Coord2` objects are given for the ``step`` and
540
+ ``origin`` parameters of :meth:`__init__`, they will be copied as immutable.
541
+ """
542
+
543
+ def __init__(self,
544
+ x1: float,
545
+ y1: float,
546
+ x2: float,
547
+ y2: float,
548
+ *,
549
+ step: CoordOrTuple2 = (1, 1),
550
+ origin: CoordOrTuple2 | None = None,
551
+ mut: bool = False,
552
+ ) -> None:
553
+ """Initializes a ``Grid2`` instance.
554
+
555
+ :param step: Default step used for :meth:`steps_x`, :meth:`steps_y`, and :meth:`steps`.
556
+ :param origin: Default origin used for :meth:`steps_x`, :meth:`steps_y`, and :meth:`steps`.
557
+ If ``None``, the origin is set to the center coordinate of ``rect``.
558
+ :param mut: Whether this instance's attributes can be modified or not. If ``False``, ``TypeError`` is raised
559
+ when attempting to reassign them, and ``step`` and ``origin`` will be made immutable as well.
560
+ """
561
+ super().__init__(x1, y1, x2, y2, mut=mut)
562
+
563
+ self._step = step if isinstance(step, Coord2) and mut \
564
+ else Coord2(*step, mut=mut)
565
+ self._origin = origin if isinstance(origin, Coord2) and mut \
566
+ else Coord2(*self.center if origin is None else origin, mut=mut)
567
+
568
+ @property
569
+ def step(self) -> Coord2:
570
+ """Default step used for :meth:`steps_x`, :meth:`steps_y`, and :meth:`steps`."""
571
+ return self._step
572
+
573
+ @step.setter
574
+ def step(self, value: CoordOrTuple2) -> None:
575
+ if not self._mut:
576
+ raise TypeError(f'Cannot modify attribute of immutable {self.__class__.__name__} instance')
577
+ self._step = value if isinstance(value, Coord2) else Coord2(*value)
578
+
579
+ @property
580
+ def origin(self) -> Coord2:
581
+ """Default origin used for :meth:`steps_x`, :meth:`steps_y`, and :meth:`steps`."""
582
+ return self._origin
583
+
584
+ @origin.setter
585
+ def origin(self, value: CoordOrTuple2) -> None:
586
+ if not self._mut:
587
+ raise TypeError(f'Cannot modify attribute of immutable {self.__class__.__name__} instance')
588
+ self._origin = value if isinstance(value, Coord2) else Coord2(*value)
589
+
590
+ @property
591
+ def mutable(self) -> bool:
592
+ """Whether this instance's attributes can be modified."""
593
+ return self._mut
594
+
595
+ def __repr__(self) -> str: # noqa: D105
596
+ return f'{self.__class__.__name__}(x1={self.x1!r}, y1={self.y1!r}, x2={self.x2!r}, y2={self.y2!r},' \
597
+ + f' step={self.step!r}, origin={self.origin!r})'
598
+
599
+ def __str__(self) -> str:
600
+ """Returns this grid in the format ``(x1, y1, x2, y2)[step=str(step), origin=str(origin)]``."""
601
+ return f'{self.as_tuple()}[step={self.step}, origin={self.origin}]'
602
+
603
+ def __copy__(self) -> Self:
604
+ """Returns a new instance with the same values as this instance.
605
+
606
+ The resulting copy's ``step`` and ``origin`` are references to this instance's respective objects. Use
607
+ ``Grid2``'s :meth:`__deepcopy__` implementation to ensure these values are copies as well.
608
+ """
609
+ return self.__class__(
610
+ self.x1,
611
+ self.y1,
612
+ self.x2,
613
+ self.y2,
614
+ step=self.step,
615
+ origin=self.origin,
616
+ mut=self.mutable,
617
+ )
618
+
619
+ def __deepcopy__(self, memo: dict) -> Self:
620
+ """Returns a new instance with the same values as this instance.
621
+
622
+ In contrast to :meth:`__copy__`, a ``Grid2`` deepcopy will also make copies of the ``step`` and ``origin``
623
+ values.
624
+ """
625
+ return self.__class__(
626
+ self.x1,
627
+ self.y1,
628
+ self.x2,
629
+ self.y2,
630
+ step=copy(self.step),
631
+ origin=copy(self.origin),
632
+ mut=self.mutable,
633
+ )
634
+
635
+ @classmethod
636
+ @override
637
+ def from_size(cls,
638
+ size: CoordOrTuple2,
639
+ center: CoordOrTuple2 | None = None,
640
+ *,
641
+ step: CoordOrTuple2 | None = None,
642
+ origin: CoordOrTuple2 | None = None,
643
+ ) -> Self:
644
+ """Returns a new grid of the given size.
645
+
646
+ Created with its top left coordinate at ``0, 0`` by default unless ``center`` is specified, where it will be
647
+ sized out from that coordinate as the origin of the rectangle. Note that this is separate from ``origin``, which
648
+ has nothing to do with the grid's physical bounds and is be used to set the ``origin`` attribute of the grid
649
+ instance.
650
+ """
651
+ rad_x, rad_y = size[0] / 2, size[1] / 2
652
+ center_x, center_y = center if center is not None else (rad_x, rad_y)
653
+
654
+ return cls(
655
+ center_x - rad_x,
656
+ center_y - rad_y,
657
+ center_x + rad_x,
658
+ center_y + rad_y,
659
+ step=step if step is not None else (1, 1),
660
+ origin=origin,
661
+ )
662
+
663
+ def steps_x(self, *, step: float | None = None, origin: float | None = None, inf: bool = False) -> Generator[float]:
664
+ """Yields X coordinates starting at ``origin`` and adding ``step`` while in range of the grid.
665
+
666
+ .. note::
667
+ Yields no items if the step value is 0, or if ``origin`` is out of bounds.
668
+
669
+ :param step: If ``None``, defaults to ``self.step.x``. If this value is 0, no values are yielded.
670
+ :param origin: If ``None``, defaults to ``self.origin.y``.
671
+ :param inf: Whether to continue yielding steps infinitely, beyond the grid's defined boundaries.
672
+ """
673
+ step = step if step is not None else self.step.x
674
+ if step == 0:
675
+ return
676
+
677
+ origin = origin if origin is not None else self.origin.x
678
+
679
+ pos = origin
680
+ while inf or (self.x1 <= pos <= self.x2):
681
+ yield pos
682
+ pos += step
683
+
684
+ def steps_y(self, *, step: float | None = None, origin: float | None = None, inf: bool = False) -> Generator[float]:
685
+ """Yields Y coordinates starting at ``origin`` and adding ``step`` while in range of the grid.
686
+
687
+ .. note::
688
+ Yields no items if the step value is 0, or if ``origin`` is out of bounds.
689
+
690
+ :param step: If ``None``, defaults to ``self.step.y``. If this value is 0, no values are yielded.
691
+ :param origin: If ``None``, defaults to ``self.origin.y``.
692
+ :param inf: Whether to continue yielding steps infinitely, beyond the grid's defined boundaries.
693
+ """
694
+ step = step if step is not None else self.step.y
695
+ if step == 0:
696
+ return
697
+
698
+ origin = origin if origin is not None else self.origin.y
699
+
700
+ pos = origin
701
+ while inf or (self.y1 <= pos <= self.y2):
702
+ yield pos
703
+ pos += step
704
+
705
+ def steps(self,
706
+ *,
707
+ step: CoordOrTuple2 | None = None,
708
+ origin: CoordOrTuple2 | None = None,
709
+ ) -> Generator[Coord2]:
710
+ """Yields coordinates from the product of :meth:`steps_x` and :meth:`steps_y`.
711
+
712
+ Coordinates are yielded going vertically first, e.g. ``(0, 0), (0, 1), (0, 2), (1, 0), (1, 1), (1, 2), ...``.
713
+
714
+ .. note::
715
+ Yields no items if both values of ``step`` are 0, or if ``origin`` is out of bounds.
716
+
717
+ :param step: If ``None``, defaults to ``self.step``.
718
+ :param origin: If ``None``, defaults to ``self.origin``.
719
+ """
720
+ step = step if step is not None else self.step
721
+ step = step if isinstance(step, Coord2) else Coord2(*step)
722
+ if step == (0, 0):
723
+ return
724
+
725
+ origin = origin if origin is not None else self.origin
726
+ origin = origin if isinstance(origin, Coord2) else Coord2(*origin)
727
+
728
+ steps_x = self.steps_x(origin=origin.x, step=step.x) if step.x else (0,)
729
+ steps_y = self.steps_y(origin=origin.y, step=step.y) if step.y else (0,)
730
+
731
+ yield from (Coord2(x, y) for x, y in product(steps_x, steps_y))
732
+
733
+ def project(self, coord: CoordOrTuple2, other_grid: 'Grid2') -> Coord2:
734
+ """Returns a :class:`Coord2` as if it were at the same relative position on another grid as this one.
735
+
736
+ >>> g1 = Grid2(-100, -100, 100, 100)
737
+ >>> g2 = Grid2(0, 0, 100, 100)
738
+ >>> assert g1.project(Coord2(0, 0), g2) == Coord2(50, 50)
739
+ """
740
+ coord = coord if isinstance(coord, Coord2) else Coord2(*coord)
741
+
742
+ tl_a, br_a = self.top_left, self.bottom_right
743
+ tl_b, br_b = other_grid.top_left, other_grid.bottom_right
744
+
745
+ offset_factor: Coord2 = (coord - tl_a) / (br_a - tl_a)
746
+
747
+ return ((br_b - tl_b) * offset_factor) + tl_b
748
+
749
+ @override
750
+ def zip_with(self,
751
+ fn: Callable[[float, float], float],
752
+ other: Rect | Tuple4[float] | float,
753
+ *,
754
+ step: CoordOrTuple2 | None = None,
755
+ origin: CoordOrTuple2 | None = None,
756
+ ) -> Self:
757
+ """Combines this and another instance or tuple's values using ``fn``.
758
+
759
+ The values used are those returned by iterating over the instance—for :class:`Grid2`, that would be :data:`x1`,
760
+ :data:`y1`, :data:`x2`, and :data:`y2`.
761
+
762
+ :param step: What to set :data:`step` to for the returned instance. If ``None``, this instance's value is used.
763
+ :param origin: What to set :data:`origin` to for the returned instance.
764
+ If ``None``, this instance's value is used.
765
+ """
766
+ if not isinstance(other, Rect | tuple):
767
+ other = (other, other, other, other)
768
+
769
+ step = self.step if step is None else step
770
+ origin = self.origin if origin is None else origin
771
+
772
+ return self.__class__(
773
+ fn(self.x1, other[0]),
774
+ fn(self.y1, other[1]),
775
+ fn(self.x2, other[2]),
776
+ fn(self.y2, other[3]),
777
+ step=step,
778
+ origin=origin,
779
+ )
geometry/util.py ADDED
@@ -0,0 +1,50 @@
1
+ """General utilities for ``geometry``."""
2
+ from collections.abc import Callable, Iterable, Iterator
3
+
4
+
5
+ def ident[T](value: T) -> T:
6
+ """Returns the passed value."""
7
+ return value
8
+
9
+ def snap_num(num: float, mult: float, snap_fn: Callable[[float], int] = round) -> float:
10
+ """Snaps ``num`` to the smallest or largest (depending on the outcome of ``snap_fn``) multiple of ``mult``.
11
+
12
+ :param snap_fn: A function which rounds a number to an integer, determining if ``num`` snaps to the closest or
13
+ farthest multiple.
14
+
15
+ >>> import math
16
+ >>> assert snap_num(4, 5, math.floor) == 0
17
+ >>> assert snap_num(1, 5, math.ceil) == 5
18
+ """
19
+ return mult * (snap_fn(num / mult))
20
+
21
+ def partition[T](it: Iterable[T], predicate: Callable[[T], bool]) -> tuple[list[T], list[T]]:
22
+ """Separates ``it`` into two lists based on whether ``predicate(i)`` is true for each given item.
23
+
24
+ The left list is items that satisfy the predicate, the right list is items that fail the predicate.
25
+ """
26
+ yes = []
27
+ no = []
28
+
29
+ for i in it:
30
+ (yes if predicate(i) else no).append(i)
31
+
32
+ return yes, no
33
+
34
+ def take_n[T](it: Iterator[T], n: int, *, strict: bool = False) -> list[T]:
35
+ """Returns ``n`` items yielded from ``it``.
36
+
37
+ :param strict: If ``False``, ``n`` is treated as a maximum and less items than it may be returned if ``it`` runs out
38
+ before reaching ``n``. If ``True``, ``StopIteration`` is raised in this scenario.
39
+
40
+ :raises StopIteration:
41
+ There are less than ``n`` items in ``it``, and ``strict`` is ``True``.
42
+ """
43
+ if not strict:
44
+ return [i for _, i in zip(range(n), it, strict=False)]
45
+
46
+ items: list[T] = []
47
+ for _ in range(n):
48
+ items.append(next(it)) # noqa: PERF401 ; we want this to propagate StopIteration
49
+
50
+ return items
@@ -0,0 +1,41 @@
1
+ Metadata-Version: 2.4
2
+ Name: py-geometry-utils
3
+ Version: 0.5.0
4
+ Summary: Utilities for working with coordinates and rectangles/grids.
5
+ Project-URL: Homepage, https://github.com/svioletg/py-geometry-utils
6
+ Project-URL: Repository, https://github.com/svioletg/py-geometry-utils
7
+ Project-URL: Documentation, https://py-geometry-utils.readthedocs.io/en/latest/
8
+ Project-URL: Changelog, https://github.com/svioletg/py-geometry-utils/blob/main/CHANGELOG.md
9
+ Project-URL: Issues, https://github.com/svioletg/py-geometry-utils/issues
10
+ Author: Seth 'Violet' Gibbs
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Requires-Python: >=3.12
19
+ Provides-Extra: dev
20
+ Requires-Dist: py-maybetype>=0.14.0; extra == 'dev'
21
+ Requires-Dist: pytest>=9.1.1; extra == 'dev'
22
+ Requires-Dist: ruff>=0.15.22; extra == 'dev'
23
+ Requires-Dist: towncrier>=25.8.0; extra == 'dev'
24
+ Requires-Dist: ty>=0.0.61; extra == 'dev'
25
+ Provides-Extra: docs
26
+ Requires-Dist: furo>=2025.12.19; extra == 'docs'
27
+ Requires-Dist: myst-parser>=5.1.0; extra == 'docs'
28
+ Requires-Dist: sphinx-autobuild>=2025.8.25; extra == 'docs'
29
+ Requires-Dist: sphinx>=9.1; extra == 'docs'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # py-geometry-utils
33
+
34
+ Provides utilities for working with coordinates, rectangles, and grids.
35
+
36
+ Initially [part of a different project](https://github.com/svioletg/py-squaremap-combiner/blob/47f30bffb5de121fbf5ce17621ce3b8127cd36c3/src/squaremap_combine/geo.py),
37
+ I wanted to use it in a few other projects so I decided to separate it out into its own package.
38
+
39
+ Documentation: <https://py-geometry-utils.readthedocs.io/en/latest/>
40
+
41
+ Changelog: <https://github.com/svioletg/py-geometry-utils/blob/main/CHANGELOG.md>
@@ -0,0 +1,6 @@
1
+ geometry/__init__.py,sha256=yS8C-PeM4_pFI3qX9YApeeeC7qsYBKFWUvDJqltxEOc,31319
2
+ geometry/util.py,sha256=a6VnZt2USvs-6wF7Yf80xCRt55FqXuO2GGOfwrZNk8Q,1813
3
+ py_geometry_utils-0.5.0.dist-info/METADATA,sha256=w3wDMcVKwlNrJJ_I6uxG0L5fMvQHusqPRsglLFVgLIo,1890
4
+ py_geometry_utils-0.5.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
5
+ py_geometry_utils-0.5.0.dist-info/licenses/LICENSE,sha256=coW-EorVELWM4yxBWqW7JZSc5G_T-2kpLnxoGGUKv5E,1071
6
+ py_geometry_utils-0.5.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Seth "Violet" Gibbs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.