Coverage for pyTooling/Cartesian3D/__init__.py: 94%
229 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-03 23:02 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-03 23:02 +0000
1# ==================================================================================================================== #
2# _____ _ _ ____ _ _ _____ ____ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ / ___|__ _ _ __| |_ ___ ___(_) __ _ _ __ |___ /| _ \ #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` || | / _` | '__| __/ _ \/ __| |/ _` | '_ \ |_ \| | | | #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| || |__| (_| | | | || __/\__ \ | (_| | | | |___) | |_| | #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____\__,_|_| \__\___||___/_|\__,_|_| |_|____/|____/ #
7# |_| |___/ |___/ #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2025-2026 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"""
32An implementation of 3D cartesian data structures for Python.
34.. seealso::
36 :mod:`pyTooling.Cartesian2D`
37 |rarr| The same data structures in two dimensions.
38 :mod:`pyTooling.Cartesian3D.Volumes`
39 |rarr| Volumes built from these points and offsets.
40"""
41from __future__ import annotations
43from math import sqrt, acos
44from typing import Union, Generic, Any, Self
46from pyTooling.Decorators import readonly, export
47from pyTooling.MetaClasses import ExtendedType
48from pyTooling.Common import getFullyQualifiedName
49from pyTooling.Cartesian2D import Coordinate
52@export
53class Point3D(Generic[Coordinate], metaclass=ExtendedType, slots=True):
54 """An implementation of a 3D cartesian point."""
56 x: Coordinate #: The x-direction coordinate.
57 y: Coordinate #: The y-direction coordinate.
58 z: Coordinate #: The z-direction coordinate.
60 def __init__(self, x: Coordinate, y: Coordinate, z: Coordinate) -> None:
61 """
62 Initializes a 3-dimensional point.
64 :param x: X-coordinate.
65 :param y: Y-coordinate.
66 :param z: Z-coordinate.
67 :raises TypeError: If x/y/z-coordinate is not of type integer or float.
68 """
69 if not isinstance(x, (int, float)):
70 ex = TypeError("Parameter 'x' is not of type integer or float.")
71 ex.add_note(f"Got type '{getFullyQualifiedName(x)}'.")
72 raise ex
74 if not isinstance(y, (int, float)):
75 ex = TypeError("Parameter 'y' is not of type integer or float.")
76 ex.add_note(f"Got type '{getFullyQualifiedName(y)}'.")
77 raise ex
79 if not isinstance(z, (int, float)):
80 ex = TypeError("Parameter 'z' is not of type integer or float.")
81 ex.add_note(f"Got type '{getFullyQualifiedName(z)}'.")
82 raise ex
84 self.x = x
85 self.y = y
86 self.z = z
88 def Copy(self) -> Self:
89 """
90 Create a new 3D-point as a copy of this 3D point.
92 :returns: Copy of this 3D-point.
94 .. seealso::
96 :meth:`+ operator <__add__>`
97 Create a new 3D-point moved by a positive 3D-offset.
98 :meth:`- operator <__sub__>`
99 Create a new 3D-point moved by a negative 3D-offset.
100 """
101 return self.__class__(self.x, self.y, self.z)
103 def ToTuple(self) -> tuple[Coordinate, Coordinate, Coordinate]:
104 """
105 Convert this 3D-Point to a simple 3-element tuple.
107 :returns: ``(x, y, z)`` tuple.
108 """
109 return self.x, self.y, self.z
111 def __add__(self, other: Any) -> Point3D[Coordinate]:
112 """
113 Adds a 3D-offset to this 3D-point and creates a new 3D-point.
115 :param other: A 3D-offset as :class:`Offset3D` or tuple.
116 :returns: A new 3D-point shifted by the 3D-offset.
117 :raises TypeError: If parameter 'other' is not a :class:`Offset3D` or tuple.
118 """
119 if isinstance(other, Offset3D):
120 return self.__class__(
121 self.x + other.xOffset,
122 self.y + other.yOffset,
123 self.z + other.zOffset
124 )
125 elif isinstance(other, tuple):
126 return self.__class__(
127 self.x + other[0],
128 self.y + other[1],
129 self.z + other[2]
130 )
131 else:
132 ex = TypeError("Parameter 'other' is not of type Offset3D or tuple.")
133 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
134 raise ex
136 def __iadd__(self, other: Any) -> Self:
137 """
138 Adds a 3D-offset to this 3D-point (inplace).
140 :param other: A 3D-offset as :class:`Offset3D` or tuple.
141 :returns: This 3D-point.
142 :raises TypeError: If parameter 'other' is not a :class:`Offset3D` or tuple.
143 """
144 if isinstance(other, Offset3D):
145 self.x += other.xOffset
146 self.y += other.yOffset
147 self.z += other.zOffset
148 elif isinstance(other, tuple):
149 self.x += other[0]
150 self.y += other[1]
151 self.z += other[2]
152 else:
153 ex = TypeError("Parameter 'other' is not of type Offset3D or tuple.")
154 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
155 raise ex
157 return self
159 def __sub__(self, other: Any) -> Union[Offset3D[Coordinate], Point3D[Coordinate]]:
160 """
161 Subtract two 3D-Points from each other and create a new 3D-offset.
163 :param other: A 3D-point as :class:`Point3D`.
164 :returns: A new 3D-offset representing the distance between these two points.
165 :raises TypeError: If parameter 'other' is not a :class:`Point3D`.
166 """
167 if isinstance(other, Point3D):
168 return Offset3D(
169 self.x - other.x,
170 self.y - other.y,
171 self.z - other.z
172 )
173 else:
174 ex = TypeError("Parameter 'other' is not of type Point3D.")
175 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
176 raise ex
178 def __isub__(self, other: Any) -> Self:
179 """
180 Subtracts a 3D-offset to this 3D-point (inplace).
182 :param other: A 3D-offset as :class:`Offset3D` or tuple.
183 :returns: This 3D-point.
184 :raises TypeError: If parameter 'other' is not a :class:`Offset3D` or tuple.
185 """
186 if isinstance(other, Offset3D):
187 self.x -= other.xOffset
188 self.y -= other.yOffset
189 self.z -= other.zOffset
190 elif isinstance(other, tuple):
191 self.x -= other[0]
192 self.y -= other[1]
193 self.z -= other[2]
194 else:
195 ex = TypeError("Parameter 'other' is not of type Offset3D or tuple.")
196 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
197 raise ex
199 return self
201 def __repr__(self) -> str:
202 """
203 Returns the 3D point's string representation.
205 :returns: The string representation of the 3D point.
206 """
207 return f"Point3D({self.x}, {self.y}, {self.z})"
209 def __str__(self) -> str:
210 """
211 Returns the 3D point's string equivalent.
213 :returns: The string equivalent of the 3D point.
214 """
215 return f"({self.x}, {self.y}, {self.z})"
218@export
219class Origin3D(Point3D[Coordinate], Generic[Coordinate]):
220 """An implementation of a 3D cartesian origin."""
222 def __init__(self) -> None:
223 """
224 Initializes a 3-dimensional origin.
225 """
226 super().__init__(0, 0, 0)
228 def Copy(self) -> Self:
229 """
230 An origin is a singular point, so it can't be copied.
232 :raises RuntimeError: Because an origin can't be copied.
233 """
234 raise RuntimeError("An origin can't be copied.")
236 def __repr__(self) -> str:
237 """
238 Returns the 3D origin's string representation.
240 :returns: The string representation of the 3D origin.
241 """
242 return f"Origin3D({self.x}, {self.y}, {self.z})"
245@export
246class Offset3D(Generic[Coordinate], metaclass=ExtendedType, slots=True):
247 """An implementation of a 3D cartesian offset."""
249 xOffset: Coordinate #: The x-direction offset
250 yOffset: Coordinate #: The y-direction offset
251 zOffset: Coordinate #: The z-direction offset
253 def __init__(self, xOffset: Coordinate, yOffset: Coordinate, zOffset: Coordinate) -> None:
254 """
255 Initializes a 3-dimensional offset.
257 :param xOffset: x-direction offset.
258 :param yOffset: y-direction offset.
259 :param zOffset: z-direction offset.
260 :raises TypeError: If x/y/z-offset is not of type integer or float.
261 """
262 if not isinstance(xOffset, (int, float)):
263 ex = TypeError("Parameter 'xOffset' is not of type integer or float.")
264 ex.add_note(f"Got type '{getFullyQualifiedName(xOffset)}'.")
265 raise ex
267 if not isinstance(yOffset, (int, float)):
268 ex = TypeError("Parameter 'yOffset' is not of type integer or float.")
269 ex.add_note(f"Got type '{getFullyQualifiedName(yOffset)}'.")
270 raise ex
272 if not isinstance(zOffset, (int, float)):
273 ex = TypeError("Parameter 'zOffset' is not of type integer or float.")
274 ex.add_note(f"Got type '{getFullyQualifiedName(zOffset)}'.")
275 raise ex
277 self.xOffset = xOffset
278 self.yOffset = yOffset
279 self.zOffset = zOffset
281 def Copy(self) -> Self:
282 """
283 Create a new 3D-offset as a copy of this 3D-offset.
285 :returns: Copy of this 3D-offset.
287 .. seealso::
289 :meth:`+ operator <__add__>`
290 Create a new 3D-offset moved by a positive 3D-offset.
291 :meth:`- operator <__sub__>`
292 Create a new 3D-offset moved by a negative 3D-offset.
293 """
294 return self.__class__(self.xOffset, self.yOffset, self.zOffset)
296 def ToTuple(self) -> tuple[Coordinate, Coordinate, Coordinate]:
297 """
298 Convert this 3D-offset to a simple 3-element tuple.
300 :returns: ``(x, y, z)`` tuple.
301 """
302 return self.xOffset, self.yOffset, self.zOffset
304 def __eq__(self, other: Any) -> bool:
305 """
306 Compare two 3D-offsets for equality.
308 :param other: Parameter to compare against.
309 :returns: ``True``, if both 3D-offsets are equal.
310 :raises TypeError: If parameter ``other`` is not of type :class:`Offset3D` or tuple.
311 """
312 if isinstance(other, Offset3D):
313 return self.xOffset == other.xOffset and self.yOffset == other.yOffset and self.zOffset == other.zOffset
314 elif isinstance(other, tuple):
315 return self.xOffset == other[0] and self.yOffset == other[1] and self.zOffset == other[2]
316 else:
317 ex = TypeError("Parameter 'other' is not of type Offset3D or tuple.")
318 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
319 raise ex
321 def __ne__(self, other: Any) -> bool:
322 """
323 Compare two 3D-offsets for inequality.
325 :param other: Parameter to compare against.
326 :returns: ``True``, if both 3D-offsets are unequal.
327 :raises TypeError: If parameter ``other`` is not of type :class:`Offset3D` or tuple.
328 """
329 return not self.__eq__(other)
331 def __neg__(self) -> Offset3D[Coordinate]:
332 """
333 Negate all components of this 3D-offset and create a new 3D-offset.
335 :returns: 3D-offset with negated offset components.
336 """
337 return self.__class__(
338 -self.xOffset,
339 -self.yOffset,
340 -self.zOffset
341 )
343 def __add__(self, other: Any) -> Offset3D[Coordinate]:
344 """
345 Adds a 3D-offset to this 3D-offset and creates a new 3D-offset.
347 :param other: A 3D-offset as :class:`Offset3D` or tuple.
348 :returns: A new 3D-offset extended by the 3D-offset.
349 :raises TypeError: If parameter 'other' is not a :class:`Offset3D` or tuple.
350 """
351 if isinstance(other, Offset3D):
352 return self.__class__(
353 self.xOffset + other.xOffset,
354 self.yOffset + other.yOffset,
355 self.zOffset + other.zOffset
356 )
357 elif isinstance(other, tuple):
358 return self.__class__(
359 self.xOffset + other[0],
360 self.yOffset + other[1],
361 self.zOffset + other[2]
362 )
363 else:
364 ex = TypeError("Parameter 'other' is not of type Offset3D or tuple.")
365 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
366 raise ex
368 def __iadd__(self, other: Any) -> Self:
369 """
370 Adds a 3D-offset to this 3D-offset (inplace).
372 :param other: A 3D-offset as :class:`Offset3D` or tuple.
373 :returns: This 3D-point.
374 :raises TypeError: If parameter 'other' is not a :class:`Offset3D` or tuple.
375 """
376 if isinstance(other, Offset3D):
377 self.xOffset += other.xOffset
378 self.yOffset += other.yOffset
379 self.zOffset += other.zOffset
380 elif isinstance(other, tuple):
381 self.xOffset += other[0]
382 self.yOffset += other[1]
383 self.zOffset += other[2]
384 else:
385 ex = TypeError("Parameter 'other' is not of type Offset3D or tuple.")
386 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
387 raise ex
389 return self
391 def __sub__(self, other: Any) -> Offset3D[Coordinate]:
392 """
393 Subtracts a 3D-offset from this 3D-offset and creates a new 3D-offset.
395 :param other: A 3D-offset as :class:`Offset3D` or tuple.
396 :returns: A new 3D-offset reduced by the 3D-offset.
397 :raises TypeError: If parameter 'other' is not a :class:`Offset3D` or tuple.
398 """
399 if isinstance(other, Offset3D):
400 return self.__class__(
401 self.xOffset - other.xOffset,
402 self.yOffset - other.yOffset,
403 self.zOffset - other.zOffset
404 )
405 elif isinstance(other, tuple):
406 return self.__class__(
407 self.xOffset - other[0],
408 self.yOffset - other[1],
409 self.zOffset - other[2]
410 )
411 else:
412 ex = TypeError("Parameter 'other' is not of type Offset3D or tuple.")
413 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
414 raise ex
416 def __isub__(self, other: Any) -> Self:
417 """
418 Subtracts a 3D-offset from this 3D-offset (inplace).
420 :param other: A 3D-offset as :class:`Offset3D` or tuple.
421 :returns: This 3D-point.
422 :raises TypeError: If parameter 'other' is not a :class:`Offset3D` or tuple.
423 """
424 if isinstance(other, Offset3D):
425 self.xOffset -= other.xOffset
426 self.yOffset -= other.yOffset
427 self.zOffset -= other.zOffset
428 elif isinstance(other, tuple):
429 self.xOffset -= other[0]
430 self.yOffset -= other[1]
431 self.zOffset -= other[2]
432 else:
433 ex = TypeError("Parameter 'other' is not of type Offset3D or tuple.")
434 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.")
435 raise ex
437 return self
439 def __repr__(self) -> str:
440 """
441 Returns the 3D offset's string representation.
443 :returns: The string representation of the 3D offset.
444 """
445 return f"Offset3D({self.xOffset}, {self.yOffset}, {self.zOffset})"
447 def __str__(self) -> str:
448 """
449 Returns the 3D offset's string equivalent.
451 :returns: The string equivalent of the 3D offset.
452 """
453 return f"({self.xOffset}, {self.yOffset}, {self.zOffset})"
456@export
457class Size3D(Generic[Coordinate], metaclass=ExtendedType, slots=True):
458 """An implementation of a 3D cartesian size."""
460 width: Coordinate #: width in x-direction.
461 height: Coordinate #: height in y-direction.
462 depth: Coordinate #: depth in z-direction.
464 def __init__(self, width: Coordinate, height: Coordinate, depth: Coordinate) -> None:
465 """
466 Initializes a 2-dimensional size.
468 :param width: width in x-direction.
469 :param height: height in y-direction.
470 :param depth: depth in z-direction.
471 :raises TypeError: If width/height/depth is not of type integer or float.
472 """
473 if not isinstance(width, (int, float)):
474 ex = TypeError("Parameter 'width' is not of type integer or float.")
475 ex.add_note(f"Got type '{getFullyQualifiedName(width)}'.")
476 raise ex
478 if not isinstance(height, (int, float)):
479 ex = TypeError("Parameter 'height' is not of type integer or float.")
480 ex.add_note(f"Got type '{getFullyQualifiedName(height)}'.")
481 raise ex
483 if not isinstance(depth, (int, float)):
484 ex = TypeError("Parameter 'depth' is not of type integer or float.")
485 ex.add_note(f"Got type '{getFullyQualifiedName(depth)}'.")
486 raise ex
488 self.width = width
489 self.height = height
490 self.depth = depth
492 def Copy(self) -> Self:
493 """
494 Create a new 3D-size as a copy of this 3D-size.
496 :returns: Copy of this 3D-size.
497 """
498 return self.__class__(self.width, self.height, self.depth)
500 def ToTuple(self) -> tuple[Coordinate, Coordinate, Coordinate]:
501 """
502 Convert this 3D-size to a simple 3-element tuple.
504 :returns: ``(width, height, depth)`` tuple.
505 """
506 return self.width, self.height, self.depth
508 def __repr__(self) -> str:
509 """
510 Returns the 3D size's string representation.
512 :returns: The string representation of the 3D size.
513 """
514 return f"Size3D({self.width}, {self.height}, {self.depth})"
516 def __str__(self) -> str:
517 """
518 Returns the 3D size's string equivalent.
520 :returns: The string equivalent of the 3D size.
521 """
522 return f"({self.width}, {self.height}, {self.depth})"
525@export
526class Segment3D(Generic[Coordinate], metaclass=ExtendedType, slots=True):
527 """An implementation of a 3D cartesian segment."""
529 start: Point3D[Coordinate] #: Start point of a segment.
530 end: Point3D[Coordinate] #: End point of a segment.
532 def __init__(self, start: Point3D[Coordinate], end: Point3D[Coordinate], copyPoints: bool = True) -> None:
533 """
534 Initializes a 3-dimensional segment.
536 :param start: Start point of the segment.
537 :param end: End point of the segment.
538 :param copyPoints: Optional, if ``True``, the given points are copied instead of referenced.
539 :raises TypeError: If start/end is not of type :class:`Point3D`.
540 """
541 if not isinstance(start, Point3D): 541 ↛ 542line 541 didn't jump to line 542 because the condition on line 541 was never true
542 ex = TypeError("Parameter 'start' is not of type Point3D.")
543 ex.add_note(f"Got type '{getFullyQualifiedName(start)}'.")
544 raise ex
546 if not isinstance(end, Point3D): 546 ↛ 547line 546 didn't jump to line 547 because the condition on line 546 was never true
547 ex = TypeError("Parameter 'end' is not of type Point3D.")
548 ex.add_note(f"Got type '{getFullyQualifiedName(end)}'.")
549 raise ex
551 self.start = start.Copy() if copyPoints else start
552 self.end = end.Copy() if copyPoints else end
555@export
556class LineSegment3D(Segment3D[Coordinate], Generic[Coordinate]):
557 """An implementation of a 3D cartesian line segment."""
559 @readonly
560 def Length(self) -> float:
561 """
562 Read-only property to return the Euclidean distance between start and end point.
564 :returns: Euclidean distance between start and end point
565 """
566 return sqrt((self.end.x - self.start.x) ** 2 + (self.end.y - self.start.y) ** 2 + (self.end.z - self.start.z) ** 2)
568 def AngleTo(self, other: LineSegment3D[Coordinate]) -> float:
569 """
570 Compute the angle between this line segment and another one.
572 :param other: The second line segment.
573 :returns: The angle in radians.
574 """
575 vectorA = self.ToOffset()
576 vectorB = other.ToOffset()
577 scalarProductAB = vectorA.xOffset * vectorB.xOffset + vectorA.yOffset * vectorB.yOffset + vectorA.zOffset * vectorB.zOffset
579 return acos(scalarProductAB / (abs(self.Length) * abs(other.Length)))
581 def ToOffset(self) -> Offset3D[Coordinate]:
582 """
583 Convert this 3D line segment to a 3D-offset.
585 :returns: 3D-offset as :class:`Offset3D`
586 """
587 return self.end - self.start
589 def ToTuple(self) -> tuple[tuple[Coordinate, Coordinate, Coordinate], tuple[Coordinate, Coordinate, Coordinate]]:
590 """
591 Convert this 3D line segment to a simple 2-element tuple of 3D-point tuples.
593 :returns: ``((x1, y1, z1), (x2, y2, z2))`` tuple.
594 """
595 return self.start.ToTuple(), self.end.ToTuple()
597 def __repr__(self) -> str:
598 """
599 Returns the 3D line segment's string representation.
601 :returns: The string representation of the 3D line segment.
602 """
603 return f"LineSegment3D({self.start}, {self.end})"
605 def __str__(self) -> str:
606 """
607 Returns the 3D line segment's string equivalent.
609 :returns: The string equivalent of the 3D line segment.
610 """
611 return f"({self.start} → {self.end})"