Coverage for pyTooling/Cartesian2D/__init__.py: 94%
188 statements
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-12 20:40 +0000
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-12 20:40 +0000
1# ==================================================================================================================== #
2# _____ _ _ ____ _ _ ____ ____ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ / ___|__ _ _ __| |_ ___ ___(_) __ _ _ __ |___ \| _ \ #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` || | / _` | '__| __/ _ \/ __| |/ _` | '_ \ __) | | | | #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| || |__| (_| | | | || __/\__ \ | (_| | | | |/ __/| |_| | #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____\__,_|_| \__\___||___/_|\__,_|_| |_|_____|____/ #
7# |_| |___/ |___/ #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2025-2025 Patrick Lehmann - Bötzingen, Germany #
15# #
16# Licensed under the Apache License, Version 2.0 (the "License"); #
17# you may not use this file except in compliance with the License. #
18# You may obtain a copy of the License at #
19# #
20# http://www.apache.org/licenses/LICENSE-2.0 #
21# #
22# Unless required by applicable law or agreed to in writing, software #
23# distributed under the License is distributed on an "AS IS" BASIS, #
24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
25# See the License for the specific language governing permissions and #
26# limitations under the License. #
27# #
28# SPDX-License-Identifier: Apache-2.0 #
29# ==================================================================================================================== #
30#
31"""An implementation of 2D cartesian data structures for Python."""
32from sys import version_info
34from math import sqrt, acos
35from typing import TypeVar, Union, Generic, Any, Tuple
37try:
38 from pyTooling.Decorators import readonly, export
39 from pyTooling.Exceptions import ToolingException
40 from pyTooling.MetaClasses import ExtendedType
41 from pyTooling.Common import getFullyQualifiedName
42except (ImportError, ModuleNotFoundError): # pragma: no cover
43 print("[pyTooling.Cartesian2D] Could not import from 'pyTooling.*'!")
45 try:
46 from Decorators import readonly, export
47 from Exceptions import ToolingException
48 from MetaClasses import ExtendedType
49 from Common import getFullyQualifiedName
50 except (ImportError, ModuleNotFoundError) as ex: # pragma: no cover
51 print("[pyTooling.Cartesian2D] Could not import directly!")
52 raise ex
55Coordinate = TypeVar("Coordinate", bound=Union[int, float])
58@export
59class Point2D(Generic[Coordinate], metaclass=ExtendedType, slots=True):
60 """An implementation of a 2D cartesian point."""
62 x: Coordinate #: The x-direction coordinate.
63 y: Coordinate #: The y-direction coordinate.
65 def __init__(self, x: Coordinate, y: Coordinate) -> None:
66 """
67 Initializes a 2-dimensional point.
69 :param x: X-coordinate.
70 :param y: Y-coordinate.
71 :raises TypeError: If x/y-coordinate is not of type integer or float.
72 """
73 if not isinstance(x, (int, float)):
74 ex = TypeError(f"Parameter 'x' is not of type integer or float.")
75 if version_info >= (3, 11): # pragma: no cover
76 ex.add_note(f"Got type '{getFullyQualifiedName(x)}'.")
77 raise ex
78 if not isinstance(y, (int, float)):
79 ex = TypeError(f"Parameter 'y' is not of type integer or float.")
80 if version_info >= (3, 11): # pragma: no cover
81 ex.add_note(f"Got type '{getFullyQualifiedName(y)}'.")
82 raise ex
84 self.x = x
85 self.y = y
87 def Copy(self) -> "Point2D[Coordinate]": # TODO: Python 3.11: -> Self:
88 """
89 Create a new 2D-point as a copy of this 2D point.
91 :returns: Copy of this 2D-point.
93 .. seealso::
95 :meth:`+ operator <__add__>`
96 Create a new 2D-point moved by a positive 2D-offset.
97 :meth:`- operator <__sub__>`
98 Create a new 2D-point moved by a negative 2D-offset.
99 """
100 return self.__class__(self.x, self.y)
102 def ToTuple(self) -> Tuple[Coordinate, Coordinate]:
103 """
104 Convert this 2D-Point to a simple 2-element tuple.
106 :returns: ``(x, y)`` tuple.
107 """
108 return self.x, self.y
110 def __add__(self, other: Any) -> "Point2D[Coordinate]":
111 """
112 Adds a 2D-offset to this 2D-point and creates a new 2D-point.
114 :param other: A 2D-offset as :class:`Offset2D` or :class:`tuple`.
115 :returns: A new 2D-point shifted by the 2D-offset.
116 :raises TypeError: If parameter 'other' is not a :class:`Offset2D` or :class:`tuple`.
117 """
118 if isinstance(other, Offset2D):
119 return self.__class__(
120 self.x + other.xOffset,
121 self.y + other.yOffset
122 )
123 elif isinstance(other, tuple):
124 return self.__class__(
125 self.x + other[0],
126 self.y + other[1]
127 )
128 else:
129 ex = TypeError(f"Parameter 'other' is not of type Offset2D or tuple.")
130 if version_info >= (3, 11): # pragma: no cover
131 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
132 raise ex
134 def __iadd__(self, other: Any) -> "Point2D[Coordinate]": # TODO: Python 3.11: -> Self:
135 """
136 Adds a 2D-offset to this 2D-point (inplace).
138 :param other: A 2D-offset as :class:`Offset2D` or :class:`tuple`.
139 :returns: This 2D-point.
140 :raises TypeError: If parameter 'other' is not a :class:`Offset2D` or :class:`tuple`.
141 """
142 if isinstance(other, Offset2D):
143 self.x += other.xOffset
144 self.y += other.yOffset
145 elif isinstance(other, tuple):
146 self.x += other[0]
147 self.y += other[1]
148 else:
149 ex = TypeError(f"Parameter 'other' is not of type Offset2D or tuple.")
150 if version_info >= (3, 11): # pragma: no cover
151 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
152 raise ex
154 return self
156 def __sub__(self, other: Any) -> Union["Offset2D[Coordinate]", "Point2D[Coordinate]"]:
157 """
158 Subtract two 2D-Points from each other and create a new 2D-offset.
160 :param other: A 2D-point as :class:`Point2D`.
161 :returns: A new 2D-offset representing the distance between these two points.
162 :raises TypeError: If parameter 'other' is not a :class:`Point2D`.
163 """
164 if isinstance(other, Point2D):
165 return Offset2D(
166 self.x - other.x,
167 self.y - other.y
168 )
169 else:
170 ex = TypeError(f"Parameter 'other' is not of type Point2D.")
171 if version_info >= (3, 11): # pragma: no cover
172 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
173 raise ex
175 def __isub__(self, other: Any) -> "Point2D[Coordinate]": # TODO: Python 3.11: -> Self:
176 """
177 Subtracts a 2D-offset to this 2D-point (inplace).
179 :param other: A 2D-offset as :class:`Offset2D` or :class:`tuple`.
180 :returns: This 2D-point.
181 :raises TypeError: If parameter 'other' is not a :class:`Offset2D` or :class:`tuple`.
182 """
183 if isinstance(other, Offset2D):
184 self.x -= other.xOffset
185 self.y -= other.yOffset
186 elif isinstance(other, tuple):
187 self.x -= other[0]
188 self.y -= other[1]
189 else:
190 ex = TypeError(f"Parameter 'other' is not of type Offset2D or tuple.")
191 if version_info >= (3, 11): # pragma: no cover
192 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
193 raise ex
195 return self
197 def __repr__(self) -> str:
198 """
199 Returns the 2D point's string representation.
201 :returns: The string representation of the 2D point.
202 """
203 return f"Point2D({self.x}, {self.y})"
205 def __str__(self) -> str:
206 """
207 Returns the 2D point's string equivalent.
209 :returns: The string equivalent of the 2D point.
210 """
211 return f"({self.x}, {self.y})"
214@export
215class Origin2D(Point2D[Coordinate], Generic[Coordinate]):
216 """An implementation of a 2D cartesian origin."""
218 def __init__(self) -> None:
219 """
220 Initializes a 2-dimensional origin.
221 """
222 super().__init__(0, 0)
224 def Copy(self) -> "Origin2D[Coordinate]": # TODO: Python 3.11: -> Self:
225 """
226 :raises RuntimeError: Because an origin can't be copied.
227 """
228 raise RuntimeError(f"An origin can't be copied.")
230 def __repr__(self) -> str:
231 """
232 Returns the 2D origin's string representation.
234 :returns: The string representation of the 2D origin.
235 """
236 return f"Origin2D({self.x}, {self.y})"
239@export
240class Offset2D(Generic[Coordinate], metaclass=ExtendedType, slots=True):
241 """An implementation of a 2D cartesian offset."""
243 xOffset: Coordinate #: The x-direction offset
244 yOffset: Coordinate #: The y-direction offset
246 def __init__(self, xOffset: Coordinate, yOffset: Coordinate) -> None:
247 """
248 Initializes a 2-dimensional offset.
250 :param xOffset: x-direction offset.
251 :param yOffset: y-direction offset.
252 :raises TypeError: If x/y-offset is not of type integer or float.
253 """
254 if not isinstance(xOffset, (int, float)):
255 ex = TypeError(f"Parameter 'xOffset' is not of type integer or float.")
256 if version_info >= (3, 11): # pragma: no cover
257 ex.add_note(f"Got type '{getFullyQualifiedName(xOffset)}'.")
258 raise ex
259 if not isinstance(yOffset, (int, float)):
260 ex = TypeError(f"Parameter 'yOffset' is not of type integer or float.")
261 if version_info >= (3, 11): # pragma: no cover
262 ex.add_note(f"Got type '{getFullyQualifiedName(yOffset)}'.")
263 raise ex
265 self.xOffset = xOffset
266 self.yOffset = yOffset
268 def Copy(self) -> "Offset2D[Coordinate]": # TODO: Python 3.11: -> Self:
269 """
270 Create a new 2D-offset as a copy of this 2D-offset.
272 :returns: Copy of this 2D-offset.
274 .. seealso::
276 :meth:`+ operator <__add__>`
277 Create a new 2D-offset moved by a positive 2D-offset.
278 :meth:`- operator <__sub__>`
279 Create a new 2D-offset moved by a negative 2D-offset.
280 """
281 return self.__class__(self.xOffset, self.yOffset)
283 def ToTuple(self) -> Tuple[Coordinate, Coordinate]:
284 """
285 Convert this 2D-offset to a simple 2-element tuple.
287 :returns: ``(x, y)`` tuple.
288 """
289 return self.xOffset, self.yOffset
291 def __eq__(self, other) -> bool:
292 """
293 Compare two 2D-offsets for equality.
295 :param other: Parameter to compare against.
296 :returns: ``True``, if both 2D-offsets are equal.
297 :raises TypeError: If parameter ``other`` is not of type :class:`Offset2D` or :class:`tuple`.
298 """
299 if isinstance(other, Offset2D):
300 return self.xOffset == other.xOffset and self.yOffset == other.yOffset
301 elif isinstance(other, tuple):
302 return self.xOffset == other[0] and self.yOffset == other[1]
303 else:
304 ex = TypeError(f"Parameter 'other' is not of type Offset2D or tuple.")
305 if version_info >= (3, 11): # pragma: no cover
306 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
307 raise ex
309 def __ne__(self, other) -> bool:
310 """
311 Compare two 2D-offsets for inequality.
313 :param other: Parameter to compare against.
314 :returns: ``True``, if both 2D-offsets are unequal.
315 :raises TypeError: If parameter ``other`` is not of type :class:`Offset2D` or :class:`tuple`.
316 """
317 return not self.__eq__(other)
319 def __neg__(self) -> "Offset2D[Coordinate]":
320 """
321 Negate all components of this 2D-offset and create a new 2D-offset.
323 :returns: 2D-offset with negated offset components.
324 """
325 return self.__class__(
326 -self.xOffset,
327 -self.yOffset
328 )
330 def __add__(self, other: Any) -> "Offset2D[Coordinate]":
331 """
332 Adds a 2D-offset to this 2D-offset and creates a new 2D-offset.
334 :param other: A 2D-offset as :class:`Offset2D` or :class:`tuple`.
335 :returns: A new 2D-offset extended by the 2D-offset.
336 :raises TypeError: If parameter 'other' is not a :class:`Offset2D` or :class:`tuple`.
337 """
338 if isinstance(other, Offset2D):
339 return self.__class__(
340 self.xOffset + other.xOffset,
341 self.yOffset + other.yOffset
342 )
343 elif isinstance(other, tuple):
344 return self.__class__(
345 self.xOffset + other[0],
346 self.yOffset + other[1]
347 )
348 else:
349 ex = TypeError(f"Parameter 'other' is not of type Offset2D or tuple.")
350 if version_info >= (3, 11): # pragma: no cover
351 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
352 raise ex
354 def __iadd__(self, other: Any) -> "Offset2D[Coordinate]": # TODO: Python 3.11: -> Self:
355 """
356 Adds a 2D-offset to this 2D-offset (inplace).
358 :param other: A 2D-offset as :class:`Offset2D` or :class:`tuple`.
359 :returns: This 2D-point.
360 :raises TypeError: If parameter 'other' is not a :class:`Offset2D` or :class:`tuple`.
361 """
362 if isinstance(other, Offset2D):
363 self.xOffset += other.xOffset
364 self.yOffset += other.yOffset
365 elif isinstance(other, tuple):
366 self.xOffset += other[0]
367 self.yOffset += other[1]
368 else:
369 ex = TypeError(f"Parameter 'other' is not of type Offset2D or tuple.")
370 if version_info >= (3, 11): # pragma: no cover
371 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
372 raise ex
374 return self
376 def __sub__(self, other: Any) -> "Offset2D[Coordinate]":
377 """
378 Subtracts a 2D-offset from this 2D-offset and creates a new 2D-offset.
380 :param other: A 2D-offset as :class:`Offset2D` or :class:`tuple`.
381 :returns: A new 2D-offset reduced by the 2D-offset.
382 :raises TypeError: If parameter 'other' is not a :class:`Offset2D` or :class:`tuple`.
383 """
384 if isinstance(other, Offset2D):
385 return self.__class__(
386 self.xOffset - other.xOffset,
387 self.yOffset - other.yOffset
388 )
389 elif isinstance(other, tuple):
390 return self.__class__(
391 self.xOffset - other[0],
392 self.yOffset - other[1]
393 )
394 else:
395 ex = TypeError(f"Parameter 'other' is not of type Offset2D or tuple.")
396 if version_info >= (3, 11): # pragma: no cover
397 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
398 raise ex
400 def __isub__(self, other: Any) -> "Offset2D[Coordinate]": # TODO: Python 3.11: -> Self:
401 """
402 Subtracts a 2D-offset from this 2D-offset (inplace).
404 :param other: A 2D-offset as :class:`Offset2D` or :class:`tuple`.
405 :returns: This 2D-point.
406 :raises TypeError: If parameter 'other' is not a :class:`Offset2D` or :class:`tuple`.
407 """
408 if isinstance(other, Offset2D):
409 self.xOffset -= other.xOffset
410 self.yOffset -= other.yOffset
411 elif isinstance(other, tuple):
412 self.xOffset -= other[0]
413 self.yOffset -= other[1]
414 else:
415 ex = TypeError(f"Parameter 'other' is not of type Offset2D or tuple.")
416 if version_info >= (3, 11): # pragma: no cover
417 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
418 raise ex
420 return self
422 def __repr__(self) -> str:
423 """
424 Returns the 2D offset's string representation.
426 :returns: The string representation of the 2D offset.
427 """
428 return f"Offset2D({self.xOffset}, {self.yOffset})"
430 def __str__(self) -> str:
431 """
432 Returns the 2D offset's string equivalent.
434 :returns: The string equivalent of the 2D offset.
435 """
436 return f"({self.xOffset}, {self.yOffset})"
439@export
440class Size2D(Generic[Coordinate], metaclass=ExtendedType, slots=True):
441 """An implementation of a 2D cartesian size."""
443 width: Coordinate #: width in x-direction.
444 height: Coordinate #: height in y-direction.
446 def __init__(self, width: Coordinate, height: Coordinate) -> None:
447 """
448 Initializes a 2-dimensional size.
450 :param width: width in x-direction.
451 :param height: height in y-direction.
452 :raises TypeError: If width/height is not of type integer or float.
453 """
454 if not isinstance(width, (int, float)):
455 ex = TypeError(f"Parameter 'width' is not of type integer or float.")
456 if version_info >= (3, 11): # pragma: no cover
457 ex.add_note(f"Got type '{getFullyQualifiedName(width)}'.")
458 raise ex
459 if not isinstance(height, (int, float)):
460 ex = TypeError(f"Parameter 'height' is not of type integer or float.")
461 if version_info >= (3, 11): # pragma: no cover
462 ex.add_note(f"Got type '{getFullyQualifiedName(height)}'.")
463 raise ex
465 self.width = width
466 self.height = height
468 def Copy(self) -> "Size2D": # TODO: Python 3.11: -> Self:
469 """
470 Create a new 2D-size as a copy of this 2D-size.
472 :returns: Copy of this 2D-size.
473 """
474 return self.__class__(self.width, self.height)
476 def ToTuple(self) -> Tuple[Coordinate, Coordinate]:
477 """
478 Convert this 2D-size to a simple 2-element tuple.
480 :return: ``(width, height)`` tuple.
481 """
482 return self.width, self.height
484 def __repr__(self) -> str:
485 """
486 Returns the 2D size's string representation.
488 :returns: The string representation of the 2D size.
489 """
490 return f"Size2D({self.width}, {self.height})"
492 def __str__(self) -> str:
493 """
494 Returns the 2D size's string equivalent.
496 :returns: The string equivalent of the 2D size.
497 """
498 return f"({self.width}, {self.height})"
501@export
502class Segment2D(Generic[Coordinate], metaclass=ExtendedType, slots=True):
503 """An implementation of a 2D cartesian segment."""
505 start: Point2D[Coordinate] #: Start point of a segment.
506 end: Point2D[Coordinate] #: End point of a segment.
508 def __init__(self, start: Point2D[Coordinate], end: Point2D[Coordinate], copyPoints: bool = True) -> None:
509 """
510 Initializes a 2-dimensional segment.
512 :param start: Start point of the segment.
513 :param end: End point of the segment.
514 :raises TypeError: If start/end is not of type Point2D.
515 """
516 if not isinstance(start, Point2D): 516 ↛ 517line 516 didn't jump to line 517 because the condition on line 516 was never true
517 ex = TypeError(f"Parameter 'start' is not of type Point2D.")
518 if version_info >= (3, 11): # pragma: no cover
519 ex.add_note(f"Got type '{getFullyQualifiedName(start)}'.")
520 raise ex
521 if not isinstance(end, Point2D): 521 ↛ 522line 521 didn't jump to line 522 because the condition on line 521 was never true
522 ex = TypeError(f"Parameter 'end' is not of type Point2D.")
523 if version_info >= (3, 11): # pragma: no cover
524 ex.add_note(f"Got type '{getFullyQualifiedName(end)}'.")
525 raise ex
527 self.start = start.Copy() if copyPoints else start
528 self.end = end.Copy() if copyPoints else end
531@export
532class LineSegment2D(Segment2D[Coordinate], Generic[Coordinate]):
533 """An implementation of a 2D cartesian line segment."""
535 @readonly
536 def Length(self) -> float:
537 """
538 Read-only property to return the Euclidean distance between start and end point.
540 :return: Euclidean distance between start and end point
541 """
542 return sqrt((self.end.x - self.start.x) ** 2 + (self.end.x - self.start.x) ** 2)
544 def AngleTo(self, other: "LineSegment2D[Coordinate]") -> float:
545 vectorA = self.ToOffset()
546 vectorB = other.ToOffset()
547 scalarProductAB = vectorA.xOffset * vectorB.xOffset + vectorA.yOffset * vectorB.yOffset
549 return acos(scalarProductAB / (abs(self.Length) * abs(other.Length)))
551 def ToOffset(self) -> Offset2D[Coordinate]:
552 """
553 Convert this 2D line segment to a 2D-offset.
555 :return: 2D-offset as :class:`Offset2D`
556 """
557 return self.end - self.start
559 def ToTuple(self) -> Tuple[Tuple[Coordinate, Coordinate], Tuple[Coordinate, Coordinate]]:
560 """
561 Convert this 2D line segment to a simple 2-element tuple of 2D-point tuples.
563 :return: ``((x1, y1), (x2, y2))`` tuple.
564 """
565 return self.start.ToTuple(), self.end.ToTuple()
567 def __repr__(self) -> str:
568 """
569 Returns the 2D line segment's string representation.
571 :returns: The string representation of the 2D line segment.
572 """
573 return f"LineSegment2D({self.start}, {self.end})"
575 def __str__(self) -> str:
576 """
577 Returns the 2D line segment's string equivalent.
579 :returns: The string equivalent of the 2D line segment.
580 """
581 return f"({self.start} → {self.end})"