Coverage for pyTooling/Cartesian3D/__init__.py: 94%
211 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 3D cartesian data structures for Python."""
32from sys import version_info
34from math import sqrt, acos
35from typing import 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
42 from pyTooling.Cartesian2D import Coordinate
43except (ImportError, ModuleNotFoundError): # pragma: no cover
44 print("[pyTooling.Cartesian2D] Could not import from 'pyTooling.*'!")
46 try:
47 from Decorators import readonly, export
48 from Exceptions import ToolingException
49 from MetaClasses import ExtendedType
50 from Common import getFullyQualifiedName
51 from Cartesian2D import Coordinate
52 except (ImportError, ModuleNotFoundError) as ex: # pragma: no cover
53 print("[pyTooling.Cartesian2D] Could not import directly!")
54 raise ex
57@export
58class Point3D(Generic[Coordinate], metaclass=ExtendedType, slots=True):
59 """An implementation of a 3D cartesian point."""
61 x: Coordinate #: The x-direction coordinate.
62 y: Coordinate #: The y-direction coordinate.
63 z: Coordinate #: The z-direction coordinate.
65 def __init__(self, x: Coordinate, y: Coordinate, z: Coordinate) -> None:
66 """
67 Initializes a 3-dimensional point.
69 :param x: X-coordinate.
70 :param y: Y-coordinate.
71 :param z: Z-coordinate.
72 :raises TypeError: If x/y/z-coordinate is not of type integer or float.
73 """
74 if not isinstance(x, (int, float)):
75 ex = TypeError(f"Parameter 'x' is not of type integer or float.")
76 if version_info >= (3, 11): # pragma: no cover
77 ex.add_note(f"Got type '{getFullyQualifiedName(x)}'.")
78 raise ex
79 if not isinstance(y, (int, float)):
80 ex = TypeError(f"Parameter 'y' is not of type integer or float.")
81 if version_info >= (3, 11): # pragma: no cover
82 ex.add_note(f"Got type '{getFullyQualifiedName(y)}'.")
83 raise ex
84 if not isinstance(z, (int, float)):
85 ex = TypeError(f"Parameter 'z' is not of type integer or float.")
86 if version_info >= (3, 11): # pragma: no cover
87 ex.add_note(f"Got type '{getFullyQualifiedName(z)}'.")
88 raise ex
90 self.x = x
91 self.y = y
92 self.z = z
94 def Copy(self) -> "Point3D[Coordinate]": # TODO: Python 3.11: -> Self:
95 """
96 Create a new 3D-point as a copy of this 3D point.
98 :return: Copy of this 3D-point.
100 .. seealso::
102 :meth:`+ operator <__add__>`
103 Create a new 3D-point moved by a positive 3D-offset.
104 :meth:`- operator <__sub__>`
105 Create a new 3D-point moved by a negative 3D-offset.
106 """
107 return self.__class__(self.x, self.y, self.z)
109 def ToTuple(self) -> Tuple[Coordinate, Coordinate, Coordinate]:
110 """
111 Convert this 3D-Point to a simple 3-element tuple.
113 :return: ``(x, y, z)`` tuple.
114 """
115 return self.x, self.y, self.z
117 def __add__(self, other: Any) -> "Point3D[Coordinate]":
118 """
119 Adds a 3D-offset to this 3D-point and creates a new 3D-point.
121 :param other: A 3D-offset as :class:`Offset3D` or :class:`tuple`.
122 :return: A new 3D-point shifted by the 3D-offset.
123 :raises TypeError: If parameter 'other' is not a :class:`Offset3D` or :class:`tuple`.
124 """
125 if isinstance(other, Offset3D):
126 return self.__class__(
127 self.x + other.xOffset,
128 self.y + other.yOffset,
129 self.z + other.zOffset
130 )
131 elif isinstance(other, tuple):
132 return self.__class__(
133 self.x + other[0],
134 self.y + other[1],
135 self.z + other[2]
136 )
137 else:
138 ex = TypeError(f"Parameter 'other' is not of type Offset3D or tuple.")
139 if version_info >= (3, 11): # pragma: no cover
140 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
141 raise ex
143 def __iadd__(self, other: Any) -> "Point3D[Coordinate]": # TODO: Python 3.11: -> Self:
144 """
145 Adds a 3D-offset to this 3D-point (inplace).
147 :param other: A 3D-offset as :class:`Offset3D` or :class:`tuple`.
148 :return: This 3D-point.
149 :raises TypeError: If parameter 'other' is not a :class:`Offset3D` or :class:`tuple`.
150 """
151 if isinstance(other, Offset3D):
152 self.x += other.xOffset
153 self.y += other.yOffset
154 self.z += other.zOffset
155 elif isinstance(other, tuple):
156 self.x += other[0]
157 self.y += other[1]
158 self.z += other[2]
159 else:
160 ex = TypeError(f"Parameter 'other' is not of type Offset3D or tuple.")
161 if version_info >= (3, 11): # pragma: no cover
162 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
163 raise ex
165 return self
167 def __sub__(self, other: Any) -> Union["Offset3D[Coordinate]", "Point3D[Coordinate]"]:
168 """
169 Subtract two 3D-Points from each other and create a new 3D-offset.
171 :param other: A 3D-point as :class:`Point3D`.
172 :return: A new 3D-offset representing the distance between these two points.
173 :raises TypeError: If parameter 'other' is not a :class:`Point3D`.
174 """
175 if isinstance(other, Point3D):
176 return Offset3D(
177 self.x - other.x,
178 self.y - other.y,
179 self.z - other.z
180 )
181 else:
182 ex = TypeError(f"Parameter 'other' is not of type Point3D.")
183 if version_info >= (3, 11): # pragma: no cover
184 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
185 raise ex
187 def __isub__(self, other: Any) -> "Point3D[Coordinate]": # TODO: Python 3.11: -> Self:
188 """
189 Subtracts a 3D-offset to this 3D-point (inplace).
191 :param other: A 3D-offset as :class:`Offset3D` or :class:`tuple`.
192 :return: This 3D-point.
193 :raises TypeError: If parameter 'other' is not a :class:`Offset3D` or :class:`tuple`.
194 """
195 if isinstance(other, Offset3D):
196 self.x -= other.xOffset
197 self.y -= other.yOffset
198 self.z -= other.zOffset
199 elif isinstance(other, tuple):
200 self.x -= other[0]
201 self.y -= other[1]
202 self.z -= other[2]
203 else:
204 ex = TypeError(f"Parameter 'other' is not of type Offset3D or tuple.")
205 if version_info >= (3, 11): # pragma: no cover
206 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
207 raise ex
209 return self
211 def __repr__(self) -> str:
212 """
213 Returns the 3D point's string representation.
215 :returns: The string representation of the 3D point.
216 """
217 return f"Point3D({self.x}, {self.y}, {self.z})"
219 def __str__(self) -> str:
220 """
221 Returns the 3D point's string equivalent.
223 :returns: The string equivalent of the 3D point.
224 """
225 return f"({self.x}, {self.y}, {self.z})"
228@export
229class Origin3D(Point3D[Coordinate], Generic[Coordinate]):
230 """An implementation of a 3D cartesian origin."""
232 def __init__(self) -> None:
233 """
234 Initializes a 3-dimensional origin.
235 """
236 super().__init__(0, 0, 0)
238 def Copy(self) -> "Origin3D[Coordinate]": # TODO: Python 3.11: -> Self:
239 """
240 :raises RuntimeError: Because an origin can't be copied.
241 """
242 raise RuntimeError(f"An origin can't be copied.")
244 def __repr__(self) -> str:
245 """
246 Returns the 3D origin's string representation.
248 :returns: The string representation of the 3D origin.
249 """
250 return f"Origin3D({self.x}, {self.y}, {self.z})"
253@export
254class Offset3D(Generic[Coordinate], metaclass=ExtendedType, slots=True):
255 """An implementation of a 3D cartesian offset."""
257 xOffset: Coordinate #: The x-direction offset
258 yOffset: Coordinate #: The y-direction offset
259 zOffset: Coordinate #: The z-direction offset
261 def __init__(self, xOffset: Coordinate, yOffset: Coordinate, zOffset: Coordinate) -> None:
262 """
263 Initializes a 3-dimensional offset.
265 :param xOffset: x-direction offset.
266 :param yOffset: y-direction offset.
267 :param zOffset: z-direction offset.
268 :raises TypeError: If x/y/z-offset is not of type integer or float.
269 """
270 if not isinstance(xOffset, (int, float)):
271 ex = TypeError(f"Parameter 'xOffset' is not of type integer or float.")
272 if version_info >= (3, 11): # pragma: no cover
273 ex.add_note(f"Got type '{getFullyQualifiedName(xOffset)}'.")
274 raise ex
275 if not isinstance(yOffset, (int, float)):
276 ex = TypeError(f"Parameter 'yOffset' is not of type integer or float.")
277 if version_info >= (3, 11): # pragma: no cover
278 ex.add_note(f"Got type '{getFullyQualifiedName(yOffset)}'.")
279 raise ex
280 if not isinstance(zOffset, (int, float)):
281 ex = TypeError(f"Parameter 'zOffset' is not of type integer or float.")
282 if version_info >= (3, 11): # pragma: no cover
283 ex.add_note(f"Got type '{getFullyQualifiedName(zOffset)}'.")
284 raise ex
286 self.xOffset = xOffset
287 self.yOffset = yOffset
288 self.zOffset = zOffset
290 def Copy(self) -> "Offset3D[Coordinate]": # TODO: Python 3.11: -> Self:
291 """
292 Create a new 3D-offset as a copy of this 3D-offset.
294 :returns: Copy of this 3D-offset.
296 .. seealso::
298 :meth:`+ operator <__add__>`
299 Create a new 3D-offset moved by a positive 3D-offset.
300 :meth:`- operator <__sub__>`
301 Create a new 3D-offset moved by a negative 3D-offset.
302 """
303 return self.__class__(self.xOffset, self.yOffset, self.zOffset)
305 def ToTuple(self) -> Tuple[Coordinate, Coordinate, Coordinate]:
306 """
307 Convert this 3D-offset to a simple 3-element tuple.
309 :returns: ``(x, y, z)`` tuple.
310 """
311 return self.xOffset, self.yOffset, self.zOffset
313 def __eq__(self, other) -> bool:
314 """
315 Compare two 3D-offsets for equality.
317 :param other: Parameter to compare against.
318 :returns: ``True``, if both 3D-offsets are equal.
319 :raises TypeError: If parameter ``other`` is not of type :class:`Offset3D` or :class:`tuple`.
320 """
321 if isinstance(other, Offset3D):
322 return self.xOffset == other.xOffset and self.yOffset == other.yOffset and self.zOffset == other.zOffset
323 elif isinstance(other, tuple):
324 return self.xOffset == other[0] and self.yOffset == other[1] and self.zOffset == other[2]
325 else:
326 ex = TypeError(f"Parameter 'other' is not of type Offset3D or tuple.")
327 if version_info >= (3, 11): # pragma: no cover
328 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
329 raise ex
331 def __ne__(self, other) -> bool:
332 """
333 Compare two 3D-offsets for inequality.
335 :param other: Parameter to compare against.
336 :returns: ``True``, if both 3D-offsets are unequal.
337 :raises TypeError: If parameter ``other`` is not of type :class:`Offset3D` or :class:`tuple`.
338 """
339 return not self.__eq__(other)
341 def __neg__(self) -> "Offset3D[Coordinate]":
342 """
343 Negate all components of this 3D-offset and create a new 3D-offset.
345 :returns: 3D-offset with negated offset components.
346 """
347 return self.__class__(
348 -self.xOffset,
349 -self.yOffset,
350 -self.zOffset
351 )
353 def __add__(self, other: Any) -> "Offset3D[Coordinate]":
354 """
355 Adds a 3D-offset to this 3D-offset and creates a new 3D-offset.
357 :param other: A 3D-offset as :class:`Offset3D` or :class:`tuple`.
358 :returns: A new 3D-offset extended by the 3D-offset.
359 :raises TypeError: If parameter 'other' is not a :class:`Offset3D` or :class:`tuple`.
360 """
361 if isinstance(other, Offset3D):
362 return self.__class__(
363 self.xOffset + other.xOffset,
364 self.yOffset + other.yOffset,
365 self.zOffset + other.zOffset
366 )
367 elif isinstance(other, tuple):
368 return self.__class__(
369 self.xOffset + other[0],
370 self.yOffset + other[1],
371 self.zOffset + other[2]
372 )
373 else:
374 ex = TypeError(f"Parameter 'other' is not of type Offset3D or tuple.")
375 if version_info >= (3, 11): # pragma: no cover
376 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
377 raise ex
379 def __iadd__(self, other: Any) -> "Offset3D[Coordinate]": # TODO: Python 3.11: -> Self:
380 """
381 Adds a 3D-offset to this 3D-offset (inplace).
383 :param other: A 3D-offset as :class:`Offset3D` or :class:`tuple`.
384 :returns: This 3D-point.
385 :raises TypeError: If parameter 'other' is not a :class:`Offset3D` or :class:`tuple`.
386 """
387 if isinstance(other, Offset3D):
388 self.xOffset += other.xOffset
389 self.yOffset += other.yOffset
390 self.zOffset += other.zOffset
391 elif isinstance(other, tuple):
392 self.xOffset += other[0]
393 self.yOffset += other[1]
394 self.zOffset += other[2]
395 else:
396 ex = TypeError(f"Parameter 'other' is not of type Offset3D or tuple.")
397 if version_info >= (3, 11): # pragma: no cover
398 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
399 raise ex
401 return self
403 def __sub__(self, other: Any) -> "Offset3D[Coordinate]":
404 """
405 Subtracts a 3D-offset from this 3D-offset and creates a new 3D-offset.
407 :param other: A 3D-offset as :class:`Offset3D` or :class:`tuple`.
408 :returns: A new 3D-offset reduced by the 3D-offset.
409 :raises TypeError: If parameter 'other' is not a :class:`Offset3D` or :class:`tuple`.
410 """
411 if isinstance(other, Offset3D):
412 return self.__class__(
413 self.xOffset - other.xOffset,
414 self.yOffset - other.yOffset,
415 self.zOffset - other.zOffset
416 )
417 elif isinstance(other, tuple):
418 return self.__class__(
419 self.xOffset - other[0],
420 self.yOffset - other[1],
421 self.zOffset - other[2]
422 )
423 else:
424 ex = TypeError(f"Parameter 'other' is not of type Offset3D or tuple.")
425 if version_info >= (3, 11): # pragma: no cover
426 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
427 raise ex
429 def __isub__(self, other: Any) -> "Offset3D[Coordinate]": # TODO: Python 3.11: -> Self:
430 """
431 Subtracts a 3D-offset from this 3D-offset (inplace).
433 :param other: A 3D-offset as :class:`Offset3D` or :class:`tuple`.
434 :returns: This 3D-point.
435 :raises TypeError: If parameter 'other' is not a :class:`Offset3D` or :class:`tuple`.
436 """
437 if isinstance(other, Offset3D):
438 self.xOffset -= other.xOffset
439 self.yOffset -= other.yOffset
440 self.zOffset -= other.zOffset
441 elif isinstance(other, tuple):
442 self.xOffset -= other[0]
443 self.yOffset -= other[1]
444 self.zOffset -= other[2]
445 else:
446 ex = TypeError(f"Parameter 'other' is not of type Offset3D or tuple.")
447 if version_info >= (3, 11): # pragma: no cover
448 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
449 raise ex
451 return self
453 def __repr__(self) -> str:
454 """
455 Returns the 3D offset's string representation.
457 :returns: The string representation of the 3D offset.
458 """
459 return f"Offset3D({self.xOffset}, {self.yOffset}, {self.zOffset})"
461 def __str__(self) -> str:
462 """
463 Returns the 3D offset's string equivalent.
465 :returns: The string equivalent of the 3D offset.
466 """
467 return f"({self.xOffset}, {self.yOffset}, {self.zOffset})"
470@export
471class Size3D(Generic[Coordinate], metaclass=ExtendedType, slots=True):
472 """An implementation of a 3D cartesian size."""
474 width: Coordinate #: width in x-direction.
475 height: Coordinate #: height in y-direction.
476 depth: Coordinate #: depth in z-direction.
478 def __init__(self, width: Coordinate, height: Coordinate, depth: Coordinate) -> None:
479 """
480 Initializes a 2-dimensional size.
482 :param width: width in x-direction.
483 :param height: height in y-direction.
484 :param depth: depth in z-direction.
485 :raises TypeError: If width/height/depth is not of type integer or float.
486 """
487 if not isinstance(width, (int, float)):
488 ex = TypeError(f"Parameter 'width' is not of type integer or float.")
489 if version_info >= (3, 11): # pragma: no cover
490 ex.add_note(f"Got type '{getFullyQualifiedName(width)}'.")
491 raise ex
492 if not isinstance(height, (int, float)):
493 ex = TypeError(f"Parameter 'height' is not of type integer or float.")
494 if version_info >= (3, 11): # pragma: no cover
495 ex.add_note(f"Got type '{getFullyQualifiedName(height)}'.")
496 raise ex
497 if not isinstance(depth, (int, float)):
498 ex = TypeError(f"Parameter 'depth' is not of type integer or float.")
499 if version_info >= (3, 11): # pragma: no cover
500 ex.add_note(f"Got type '{getFullyQualifiedName(depth)}'.")
501 raise ex
503 self.width = width
504 self.height = height
505 self.depth = depth
507 def Copy(self) -> "Size3D[Coordinate]": # TODO: Python 3.11: -> Self:
508 """
509 Create a new 3D-size as a copy of this 3D-size.
511 :returns: Copy of this 3D-size.
512 """
513 return self.__class__(self.width, self.height, self.depth)
515 def ToTuple(self) -> Tuple[Coordinate, Coordinate, Coordinate]:
516 """
517 Convert this 3D-size to a simple 3-element tuple.
519 :return: ``(width, height, depth)`` tuple.
520 """
521 return self.width, self.height, self.depth
523 def __repr__(self) -> str:
524 """
525 Returns the 3D size's string representation.
527 :returns: The string representation of the 3D size.
528 """
529 return f"Size3D({self.width}, {self.height}, {self.depth})"
531 def __str__(self) -> str:
532 """
533 Returns the 3D size's string equivalent.
535 :returns: The string equivalent of the 3D size.
536 """
537 return f"({self.width}, {self.height}, {self.depth})"
540@export
541class Segment3D(Generic[Coordinate], metaclass=ExtendedType, slots=True):
542 """An implementation of a 3D cartesian segment."""
544 start: Point3D[Coordinate] #: Start point of a segment.
545 end: Point3D[Coordinate] #: End point of a segment.
547 def __init__(self, start: Point3D[Coordinate], end: Point3D[Coordinate], copyPoints: bool = True) -> None:
548 """
549 Initializes a 3-dimensional segment.
551 :param start: Start point of the segment.
552 :param end: End point of the segment.
553 :raises TypeError: If start/end is not of type Point3D.
554 """
555 if not isinstance(start, Point3D): 555 ↛ 556line 555 didn't jump to line 556 because the condition on line 555 was never true
556 ex = TypeError(f"Parameter 'start' is not of type Point3D.")
557 if version_info >= (3, 11): # pragma: no cover
558 ex.add_note(f"Got type '{getFullyQualifiedName(start)}'.")
559 raise ex
560 if not isinstance(end, Point3D): 560 ↛ 561line 560 didn't jump to line 561 because the condition on line 560 was never true
561 ex = TypeError(f"Parameter 'end' is not of type Point3D.")
562 if version_info >= (3, 11): # pragma: no cover
563 ex.add_note(f"Got type '{getFullyQualifiedName(end)}'.")
564 raise ex
566 self.start = start.Copy() if copyPoints else start
567 self.end = end.Copy() if copyPoints else end
570@export
571class LineSegment3D(Segment3D[Coordinate], Generic[Coordinate]):
572 """An implementation of a 3D cartesian line segment."""
574 @readonly
575 def Length(self) -> float:
576 """
577 Read-only property to return the Euclidean distance between start and end point.
579 :return: Euclidean distance between start and end point
580 """
581 return sqrt((self.end.x - self.start.x) ** 2 + (self.end.y - self.start.y) ** 2 + (self.end.z - self.start.z) ** 2)
583 def AngleTo(self, other: "LineSegment3D[Coordinate]") -> float:
584 vectorA = self.ToOffset()
585 vectorB = other.ToOffset()
586 scalarProductAB = vectorA.xOffset * vectorB.xOffset + vectorA.yOffset * vectorB.yOffset + vectorA.zOffset * vectorB.zOffset
588 return acos(scalarProductAB / (abs(self.Length) * abs(other.Length)))
590 def ToOffset(self) -> Offset3D[Coordinate]:
591 """
592 Convert this 3D line segment to a 3D-offset.
594 :return: 3D-offset as :class:`Offset3D`
595 """
596 return self.end - self.start
598 def ToTuple(self) -> Tuple[Tuple[Coordinate, Coordinate, Coordinate], Tuple[Coordinate, Coordinate, Coordinate]]:
599 """
600 Convert this 3D line segment to a simple 2-element tuple of 3D-point tuples.
602 :return: ``((x1, y1, z1), (x2, y2, z2))`` tuple.
603 """
604 return self.start.ToTuple(), self.end.ToTuple()
606 def __repr__(self) -> str:
607 """
608 Returns the 3D line segment's string representation.
610 :returns: The string representation of the 3D line segment.
611 """
612 return f"LineSegment3D({self.start}, {self.end})"
614 def __str__(self) -> str:
615 """
616 Returns the 3D line segment's string equivalent.
618 :returns: The string equivalent of the 3D line segment.
619 """
620 return f"({self.start} → {self.end})"