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

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. 

33 

34.. seealso:: 

35 

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 

42 

43from math import sqrt, acos 

44from typing import Union, Generic, Any, Self 

45 

46from pyTooling.Decorators import readonly, export 

47from pyTooling.MetaClasses import ExtendedType 

48from pyTooling.Common import getFullyQualifiedName 

49from pyTooling.Cartesian2D import Coordinate 

50 

51 

52@export 

53class Point3D(Generic[Coordinate], metaclass=ExtendedType, slots=True): 

54 """An implementation of a 3D cartesian point.""" 

55 

56 x: Coordinate #: The x-direction coordinate. 

57 y: Coordinate #: The y-direction coordinate. 

58 z: Coordinate #: The z-direction coordinate. 

59 

60 def __init__(self, x: Coordinate, y: Coordinate, z: Coordinate) -> None: 

61 """ 

62 Initializes a 3-dimensional point. 

63 

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 

73 

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 

78 

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 

83 

84 self.x = x 

85 self.y = y 

86 self.z = z 

87 

88 def Copy(self) -> Self: 

89 """ 

90 Create a new 3D-point as a copy of this 3D point. 

91 

92 :returns: Copy of this 3D-point. 

93 

94 .. seealso:: 

95 

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) 

102 

103 def ToTuple(self) -> tuple[Coordinate, Coordinate, Coordinate]: 

104 """ 

105 Convert this 3D-Point to a simple 3-element tuple. 

106 

107 :returns: ``(x, y, z)`` tuple. 

108 """ 

109 return self.x, self.y, self.z 

110 

111 def __add__(self, other: Any) -> Point3D[Coordinate]: 

112 """ 

113 Adds a 3D-offset to this 3D-point and creates a new 3D-point. 

114 

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 

135 

136 def __iadd__(self, other: Any) -> Self: 

137 """ 

138 Adds a 3D-offset to this 3D-point (inplace). 

139 

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 

156 

157 return self 

158 

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. 

162 

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 

177 

178 def __isub__(self, other: Any) -> Self: 

179 """ 

180 Subtracts a 3D-offset to this 3D-point (inplace). 

181 

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 

198 

199 return self 

200 

201 def __repr__(self) -> str: 

202 """ 

203 Returns the 3D point's string representation. 

204 

205 :returns: The string representation of the 3D point. 

206 """ 

207 return f"Point3D({self.x}, {self.y}, {self.z})" 

208 

209 def __str__(self) -> str: 

210 """ 

211 Returns the 3D point's string equivalent. 

212 

213 :returns: The string equivalent of the 3D point. 

214 """ 

215 return f"({self.x}, {self.y}, {self.z})" 

216 

217 

218@export 

219class Origin3D(Point3D[Coordinate], Generic[Coordinate]): 

220 """An implementation of a 3D cartesian origin.""" 

221 

222 def __init__(self) -> None: 

223 """ 

224 Initializes a 3-dimensional origin. 

225 """ 

226 super().__init__(0, 0, 0) 

227 

228 def Copy(self) -> Self: 

229 """ 

230 An origin is a singular point, so it can't be copied. 

231 

232 :raises RuntimeError: Because an origin can't be copied. 

233 """ 

234 raise RuntimeError("An origin can't be copied.") 

235 

236 def __repr__(self) -> str: 

237 """ 

238 Returns the 3D origin's string representation. 

239 

240 :returns: The string representation of the 3D origin. 

241 """ 

242 return f"Origin3D({self.x}, {self.y}, {self.z})" 

243 

244 

245@export 

246class Offset3D(Generic[Coordinate], metaclass=ExtendedType, slots=True): 

247 """An implementation of a 3D cartesian offset.""" 

248 

249 xOffset: Coordinate #: The x-direction offset 

250 yOffset: Coordinate #: The y-direction offset 

251 zOffset: Coordinate #: The z-direction offset 

252 

253 def __init__(self, xOffset: Coordinate, yOffset: Coordinate, zOffset: Coordinate) -> None: 

254 """ 

255 Initializes a 3-dimensional offset. 

256 

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 

266 

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 

271 

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 

276 

277 self.xOffset = xOffset 

278 self.yOffset = yOffset 

279 self.zOffset = zOffset 

280 

281 def Copy(self) -> Self: 

282 """ 

283 Create a new 3D-offset as a copy of this 3D-offset. 

284 

285 :returns: Copy of this 3D-offset. 

286 

287 .. seealso:: 

288 

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) 

295 

296 def ToTuple(self) -> tuple[Coordinate, Coordinate, Coordinate]: 

297 """ 

298 Convert this 3D-offset to a simple 3-element tuple. 

299 

300 :returns: ``(x, y, z)`` tuple. 

301 """ 

302 return self.xOffset, self.yOffset, self.zOffset 

303 

304 def __eq__(self, other: Any) -> bool: 

305 """ 

306 Compare two 3D-offsets for equality. 

307 

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 

320 

321 def __ne__(self, other: Any) -> bool: 

322 """ 

323 Compare two 3D-offsets for inequality. 

324 

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) 

330 

331 def __neg__(self) -> Offset3D[Coordinate]: 

332 """ 

333 Negate all components of this 3D-offset and create a new 3D-offset. 

334 

335 :returns: 3D-offset with negated offset components. 

336 """ 

337 return self.__class__( 

338 -self.xOffset, 

339 -self.yOffset, 

340 -self.zOffset 

341 ) 

342 

343 def __add__(self, other: Any) -> Offset3D[Coordinate]: 

344 """ 

345 Adds a 3D-offset to this 3D-offset and creates a new 3D-offset. 

346 

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 

367 

368 def __iadd__(self, other: Any) -> Self: 

369 """ 

370 Adds a 3D-offset to this 3D-offset (inplace). 

371 

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 

388 

389 return self 

390 

391 def __sub__(self, other: Any) -> Offset3D[Coordinate]: 

392 """ 

393 Subtracts a 3D-offset from this 3D-offset and creates a new 3D-offset. 

394 

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 

415 

416 def __isub__(self, other: Any) -> Self: 

417 """ 

418 Subtracts a 3D-offset from this 3D-offset (inplace). 

419 

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 

436 

437 return self 

438 

439 def __repr__(self) -> str: 

440 """ 

441 Returns the 3D offset's string representation. 

442 

443 :returns: The string representation of the 3D offset. 

444 """ 

445 return f"Offset3D({self.xOffset}, {self.yOffset}, {self.zOffset})" 

446 

447 def __str__(self) -> str: 

448 """ 

449 Returns the 3D offset's string equivalent. 

450 

451 :returns: The string equivalent of the 3D offset. 

452 """ 

453 return f"({self.xOffset}, {self.yOffset}, {self.zOffset})" 

454 

455 

456@export 

457class Size3D(Generic[Coordinate], metaclass=ExtendedType, slots=True): 

458 """An implementation of a 3D cartesian size.""" 

459 

460 width: Coordinate #: width in x-direction. 

461 height: Coordinate #: height in y-direction. 

462 depth: Coordinate #: depth in z-direction. 

463 

464 def __init__(self, width: Coordinate, height: Coordinate, depth: Coordinate) -> None: 

465 """ 

466 Initializes a 2-dimensional size. 

467 

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 

477 

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 

482 

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 

487 

488 self.width = width 

489 self.height = height 

490 self.depth = depth 

491 

492 def Copy(self) -> Self: 

493 """ 

494 Create a new 3D-size as a copy of this 3D-size. 

495 

496 :returns: Copy of this 3D-size. 

497 """ 

498 return self.__class__(self.width, self.height, self.depth) 

499 

500 def ToTuple(self) -> tuple[Coordinate, Coordinate, Coordinate]: 

501 """ 

502 Convert this 3D-size to a simple 3-element tuple. 

503 

504 :returns: ``(width, height, depth)`` tuple. 

505 """ 

506 return self.width, self.height, self.depth 

507 

508 def __repr__(self) -> str: 

509 """ 

510 Returns the 3D size's string representation. 

511 

512 :returns: The string representation of the 3D size. 

513 """ 

514 return f"Size3D({self.width}, {self.height}, {self.depth})" 

515 

516 def __str__(self) -> str: 

517 """ 

518 Returns the 3D size's string equivalent. 

519 

520 :returns: The string equivalent of the 3D size. 

521 """ 

522 return f"({self.width}, {self.height}, {self.depth})" 

523 

524 

525@export 

526class Segment3D(Generic[Coordinate], metaclass=ExtendedType, slots=True): 

527 """An implementation of a 3D cartesian segment.""" 

528 

529 start: Point3D[Coordinate] #: Start point of a segment. 

530 end: Point3D[Coordinate] #: End point of a segment. 

531 

532 def __init__(self, start: Point3D[Coordinate], end: Point3D[Coordinate], copyPoints: bool = True) -> None: 

533 """ 

534 Initializes a 3-dimensional segment. 

535 

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 

545 

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 

550 

551 self.start = start.Copy() if copyPoints else start 

552 self.end = end.Copy() if copyPoints else end 

553 

554 

555@export 

556class LineSegment3D(Segment3D[Coordinate], Generic[Coordinate]): 

557 """An implementation of a 3D cartesian line segment.""" 

558 

559 @readonly 

560 def Length(self) -> float: 

561 """ 

562 Read-only property to return the Euclidean distance between start and end point. 

563 

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) 

567 

568 def AngleTo(self, other: LineSegment3D[Coordinate]) -> float: 

569 """ 

570 Compute the angle between this line segment and another one. 

571 

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 

578 

579 return acos(scalarProductAB / (abs(self.Length) * abs(other.Length))) 

580 

581 def ToOffset(self) -> Offset3D[Coordinate]: 

582 """ 

583 Convert this 3D line segment to a 3D-offset. 

584 

585 :returns: 3D-offset as :class:`Offset3D` 

586 """ 

587 return self.end - self.start 

588 

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. 

592 

593 :returns: ``((x1, y1, z1), (x2, y2, z2))`` tuple. 

594 """ 

595 return self.start.ToTuple(), self.end.ToTuple() 

596 

597 def __repr__(self) -> str: 

598 """ 

599 Returns the 3D line segment's string representation. 

600 

601 :returns: The string representation of the 3D line segment. 

602 """ 

603 return f"LineSegment3D({self.start}, {self.end})" 

604 

605 def __str__(self) -> str: 

606 """ 

607 Returns the 3D line segment's string equivalent. 

608 

609 :returns: The string equivalent of the 3D line segment. 

610 """ 

611 return f"({self.start} → {self.end})"