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,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.
|