Coverage for pyTooling/Cartesian2D/__init__.py: 93%

203 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-22 21:29 +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 2D cartesian data structures for Python. 

33 

34.. seealso:: 

35 

36 :mod:`pyTooling.Cartesian3D` 

37 |rarr| The same data structures in three dimensions. 

38 :mod:`pyTooling.Cartesian2D.Shapes` 

39 |rarr| Shapes built from these points and offsets. 

40""" 

41from __future__ import annotations 

42 

43from math import sqrt, acos 

44from typing import TypeVar, Union, Generic, Any, Self 

45 

46from pyTooling.Decorators import readonly, export 

47from pyTooling.MetaClasses import ExtendedType 

48from pyTooling.Common import getFullyQualifiedName 

49 

50 

51Coordinate = TypeVar("Coordinate", bound=Union[int, float]) 

52 

53 

54@export 

55class Point2D(Generic[Coordinate], metaclass=ExtendedType, slots=True): 

56 """An implementation of a 2D cartesian point.""" 

57 

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

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

60 

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

62 """ 

63 Initializes a 2-dimensional point. 

64 

65 :param x: X-coordinate. 

66 :param y: Y-coordinate. 

67 :raises TypeError: If x/y-coordinate is not of type integer or float. 

68 """ 

69 if not isinstance(x, (int, float)): 

70 ex = TypeError(f"Parameter 'x' is not of type integer or float.") 

71 ex.add_note(f"Got type '{getFullyQualifiedName(x)}'.") 

72 raise ex 

73 if not isinstance(y, (int, float)): 

74 ex = TypeError(f"Parameter 'y' is not of type integer or float.") 

75 ex.add_note(f"Got type '{getFullyQualifiedName(y)}'.") 

76 raise ex 

77 

78 self.x = x 

79 self.y = y 

80 

81 def Copy(self) -> Self: 

82 """ 

83 Create a new 2D-point as a copy of this 2D point. 

84 

85 :returns: Copy of this 2D-point. 

86 

87 .. seealso:: 

88 

89 :meth:`+ operator <__add__>` 

90 Create a new 2D-point moved by a positive 2D-offset. 

91 :meth:`- operator <__sub__>` 

92 Create a new 2D-point moved by a negative 2D-offset. 

93 """ 

94 return self.__class__(self.x, self.y) 

95 

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

97 """ 

98 Convert this 2D-Point to a simple 2-element tuple. 

99 

100 :returns: ``(x, y)`` tuple. 

101 """ 

102 return self.x, self.y 

103 

104 def __add__(self, other: Any) -> Point2D[Coordinate]: 

105 """ 

106 Adds a 2D-offset to this 2D-point and creates a new 2D-point. 

107 

108 :param other: A 2D-offset as :class:`Offset2D` or tuple. 

109 :returns: A new 2D-point shifted by the 2D-offset. 

110 :raises TypeError: If parameter 'other' is not a :class:`Offset2D` or tuple. 

111 """ 

112 if isinstance(other, Offset2D): 

113 return self.__class__( 

114 self.x + other.xOffset, 

115 self.y + other.yOffset 

116 ) 

117 elif isinstance(other, tuple): 

118 return self.__class__( 

119 self.x + other[0], 

120 self.y + other[1] 

121 ) 

122 else: 

123 ex = TypeError(f"Parameter 'other' is not of type Offset2D or tuple.") 

124 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.") 

125 raise ex 

126 

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

128 """ 

129 Adds a 2D-offset to this 2D-point (inplace). 

130 

131 :param other: A 2D-offset as :class:`Offset2D` or tuple. 

132 :returns: This 2D-point. 

133 :raises TypeError: If parameter 'other' is not a :class:`Offset2D` or tuple. 

134 """ 

135 if isinstance(other, Offset2D): 

136 self.x += other.xOffset 

137 self.y += other.yOffset 

138 elif isinstance(other, tuple): 

139 self.x += other[0] 

140 self.y += other[1] 

141 else: 

142 ex = TypeError(f"Parameter 'other' is not of type Offset2D or tuple.") 

143 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.") 

144 raise ex 

145 

146 return self 

147 

148 def __sub__(self, other: Any) -> Union[Offset2D[Coordinate], Point2D[Coordinate]]: 

149 """ 

150 Subtract two 2D-Points from each other and create a new 2D-offset. 

151 

152 :param other: A 2D-point as :class:`Point2D`. 

153 :returns: A new 2D-offset representing the distance between these two points. 

154 :raises TypeError: If parameter 'other' is not a :class:`Point2D`. 

155 """ 

156 if isinstance(other, Point2D): 

157 return Offset2D( 

158 self.x - other.x, 

159 self.y - other.y 

160 ) 

161 else: 

162 ex = TypeError(f"Parameter 'other' is not of type Point2D.") 

163 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.") 

164 raise ex 

165 

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

167 """ 

168 Subtracts a 2D-offset to this 2D-point (inplace). 

169 

170 :param other: A 2D-offset as :class:`Offset2D` or tuple. 

171 :returns: This 2D-point. 

172 :raises TypeError: If parameter 'other' is not a :class:`Offset2D` or tuple. 

173 """ 

174 if isinstance(other, Offset2D): 

175 self.x -= other.xOffset 

176 self.y -= other.yOffset 

177 elif isinstance(other, tuple): 

178 self.x -= other[0] 

179 self.y -= other[1] 

180 else: 

181 ex = TypeError(f"Parameter 'other' is not of type Offset2D or tuple.") 

182 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.") 

183 raise ex 

184 

185 return self 

186 

187 def __repr__(self) -> str: 

188 """ 

189 Returns the 2D point's string representation. 

190 

191 :returns: The string representation of the 2D point. 

192 """ 

193 return f"Point2D({self.x}, {self.y})" 

194 

195 def __str__(self) -> str: 

196 """ 

197 Returns the 2D point's string equivalent. 

198 

199 :returns: The string equivalent of the 2D point. 

200 """ 

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

202 

203 

204@export 

205class Origin2D(Point2D[Coordinate], Generic[Coordinate]): 

206 """An implementation of a 2D cartesian origin.""" 

207 

208 def __init__(self) -> None: 

209 """ 

210 Initializes a 2-dimensional origin. 

211 """ 

212 super().__init__(0, 0) 

213 

214 def Copy(self) -> Self: 

215 """ 

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

217 

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

219 """ 

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

221 

222 def __repr__(self) -> str: 

223 """ 

224 Returns the 2D origin's string representation. 

225 

226 :returns: The string representation of the 2D origin. 

227 """ 

228 return f"Origin2D({self.x}, {self.y})" 

229 

230 

231@export 

232class Offset2D(Generic[Coordinate], metaclass=ExtendedType, slots=True): 

233 """An implementation of a 2D cartesian offset.""" 

234 

235 xOffset: Coordinate #: The x-direction offset 

236 yOffset: Coordinate #: The y-direction offset 

237 

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

239 """ 

240 Initializes a 2-dimensional offset. 

241 

242 :param xOffset: x-direction offset. 

243 :param yOffset: y-direction offset. 

244 :raises TypeError: If x/y-offset is not of type integer or float. 

245 """ 

246 if not isinstance(xOffset, (int, float)): 

247 ex = TypeError(f"Parameter 'xOffset' is not of type integer or float.") 

248 ex.add_note(f"Got type '{getFullyQualifiedName(xOffset)}'.") 

249 raise ex 

250 if not isinstance(yOffset, (int, float)): 

251 ex = TypeError(f"Parameter 'yOffset' is not of type integer or float.") 

252 ex.add_note(f"Got type '{getFullyQualifiedName(yOffset)}'.") 

253 raise ex 

254 

255 self.xOffset = xOffset 

256 self.yOffset = yOffset 

257 

258 def Copy(self) -> Self: 

259 """ 

260 Create a new 2D-offset as a copy of this 2D-offset. 

261 

262 :returns: Copy of this 2D-offset. 

263 

264 .. seealso:: 

265 

266 :meth:`+ operator <__add__>` 

267 Create a new 2D-offset moved by a positive 2D-offset. 

268 :meth:`- operator <__sub__>` 

269 Create a new 2D-offset moved by a negative 2D-offset. 

270 """ 

271 return self.__class__(self.xOffset, self.yOffset) 

272 

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

274 """ 

275 Convert this 2D-offset to a simple 2-element tuple. 

276 

277 :returns: ``(x, y)`` tuple. 

278 """ 

279 return self.xOffset, self.yOffset 

280 

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

282 """ 

283 Compare two 2D-offsets for equality. 

284 

285 :param other: Parameter to compare against. 

286 :returns: ``True``, if both 2D-offsets are equal. 

287 :raises TypeError: If parameter ``other`` is not of type :class:`Offset2D` or tuple. 

288 """ 

289 if isinstance(other, Offset2D): 

290 return self.xOffset == other.xOffset and self.yOffset == other.yOffset 

291 elif isinstance(other, tuple): 

292 return self.xOffset == other[0] and self.yOffset == other[1] 

293 else: 

294 ex = TypeError(f"Parameter 'other' is not of type Offset2D or tuple.") 

295 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.") 

296 raise ex 

297 

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

299 """ 

300 Compare two 2D-offsets for inequality. 

301 

302 :param other: Parameter to compare against. 

303 :returns: ``True``, if both 2D-offsets are unequal. 

304 :raises TypeError: If parameter ``other`` is not of type :class:`Offset2D` or tuple. 

305 """ 

306 return not self.__eq__(other) 

307 

308 def __neg__(self) -> Offset2D[Coordinate]: 

309 """ 

310 Negate all components of this 2D-offset and create a new 2D-offset. 

311 

312 :returns: 2D-offset with negated offset components. 

313 """ 

314 return self.__class__( 

315 -self.xOffset, 

316 -self.yOffset 

317 ) 

318 

319 def __add__(self, other: Any) -> Offset2D[Coordinate]: 

320 """ 

321 Adds a 2D-offset to this 2D-offset and creates a new 2D-offset. 

322 

323 :param other: A 2D-offset as :class:`Offset2D` or tuple. 

324 :returns: A new 2D-offset extended by the 2D-offset. 

325 :raises TypeError: If parameter 'other' is not a :class:`Offset2D` or tuple. 

326 """ 

327 if isinstance(other, Offset2D): 

328 return self.__class__( 

329 self.xOffset + other.xOffset, 

330 self.yOffset + other.yOffset 

331 ) 

332 elif isinstance(other, tuple): 

333 return self.__class__( 

334 self.xOffset + other[0], 

335 self.yOffset + other[1] 

336 ) 

337 else: 

338 ex = TypeError(f"Parameter 'other' is not of type Offset2D or tuple.") 

339 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.") 

340 raise ex 

341 

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

343 """ 

344 Adds a 2D-offset to this 2D-offset (inplace). 

345 

346 :param other: A 2D-offset as :class:`Offset2D` or tuple. 

347 :returns: This 2D-point. 

348 :raises TypeError: If parameter 'other' is not a :class:`Offset2D` or tuple. 

349 """ 

350 if isinstance(other, Offset2D): 

351 self.xOffset += other.xOffset 

352 self.yOffset += other.yOffset 

353 elif isinstance(other, tuple): 

354 self.xOffset += other[0] 

355 self.yOffset += other[1] 

356 else: 

357 ex = TypeError(f"Parameter 'other' is not of type Offset2D or tuple.") 

358 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.") 

359 raise ex 

360 

361 return self 

362 

363 def __sub__(self, other: Any) -> Offset2D[Coordinate]: 

364 """ 

365 Subtracts a 2D-offset from this 2D-offset and creates a new 2D-offset. 

366 

367 :param other: A 2D-offset as :class:`Offset2D` or tuple. 

368 :returns: A new 2D-offset reduced by the 2D-offset. 

369 :raises TypeError: If parameter 'other' is not a :class:`Offset2D` or tuple. 

370 """ 

371 if isinstance(other, Offset2D): 

372 return self.__class__( 

373 self.xOffset - other.xOffset, 

374 self.yOffset - other.yOffset 

375 ) 

376 elif isinstance(other, tuple): 

377 return self.__class__( 

378 self.xOffset - other[0], 

379 self.yOffset - other[1] 

380 ) 

381 else: 

382 ex = TypeError(f"Parameter 'other' is not of type Offset2D or tuple.") 

383 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.") 

384 raise ex 

385 

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

387 """ 

388 Subtracts a 2D-offset from this 2D-offset (inplace). 

389 

390 :param other: A 2D-offset as :class:`Offset2D` or tuple. 

391 :returns: This 2D-point. 

392 :raises TypeError: If parameter 'other' is not a :class:`Offset2D` or tuple. 

393 """ 

394 if isinstance(other, Offset2D): 

395 self.xOffset -= other.xOffset 

396 self.yOffset -= other.yOffset 

397 elif isinstance(other, tuple): 

398 self.xOffset -= other[0] 

399 self.yOffset -= other[1] 

400 else: 

401 ex = TypeError(f"Parameter 'other' is not of type Offset2D or tuple.") 

402 ex.add_note(f"Got type '{getFullyQualifiedName(other)}'.") 

403 raise ex 

404 

405 return self 

406 

407 def __repr__(self) -> str: 

408 """ 

409 Returns the 2D offset's string representation. 

410 

411 :returns: The string representation of the 2D offset. 

412 """ 

413 return f"Offset2D({self.xOffset}, {self.yOffset})" 

414 

415 def __str__(self) -> str: 

416 """ 

417 Returns the 2D offset's string equivalent. 

418 

419 :returns: The string equivalent of the 2D offset. 

420 """ 

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

422 

423 

424@export 

425class Size2D(Generic[Coordinate], metaclass=ExtendedType, slots=True): 

426 """An implementation of a 2D cartesian size.""" 

427 

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

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

430 

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

432 """ 

433 Initializes a 2-dimensional size. 

434 

435 :param width: width in x-direction. 

436 :param height: height in y-direction. 

437 :raises TypeError: If width/height is not of type integer or float. 

438 """ 

439 if not isinstance(width, (int, float)): 

440 ex = TypeError(f"Parameter 'width' is not of type integer or float.") 

441 ex.add_note(f"Got type '{getFullyQualifiedName(width)}'.") 

442 raise ex 

443 if not isinstance(height, (int, float)): 

444 ex = TypeError(f"Parameter 'height' is not of type integer or float.") 

445 ex.add_note(f"Got type '{getFullyQualifiedName(height)}'.") 

446 raise ex 

447 

448 self.width = width 

449 self.height = height 

450 

451 def Copy(self) -> Self: 

452 """ 

453 Create a new 2D-size as a copy of this 2D-size. 

454 

455 :returns: Copy of this 2D-size. 

456 """ 

457 return self.__class__(self.width, self.height) 

458 

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

460 """ 

461 Convert this 2D-size to a simple 2-element tuple. 

462 

463 :returns: ``(width, height)`` tuple. 

464 """ 

465 return self.width, self.height 

466 

467 def __repr__(self) -> str: 

468 """ 

469 Returns the 2D size's string representation. 

470 

471 :returns: The string representation of the 2D size. 

472 """ 

473 return f"Size2D({self.width}, {self.height})" 

474 

475 def __str__(self) -> str: 

476 """ 

477 Returns the 2D size's string equivalent. 

478 

479 :returns: The string equivalent of the 2D size. 

480 """ 

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

482 

483 

484@export 

485class Segment2D(Generic[Coordinate], metaclass=ExtendedType, slots=True): 

486 """An implementation of a 2D cartesian segment.""" 

487 

488 start: Point2D[Coordinate] #: Start point of a segment. 

489 end: Point2D[Coordinate] #: End point of a segment. 

490 

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

492 """ 

493 Initializes a 2-dimensional segment. 

494 

495 :param start: Start point of the segment. 

496 :param end: End point of the segment. 

497 :param copyPoints: Optional, if ``True``, the given points are copied instead of referenced. 

498 :raises TypeError: If start/end is not of type :class:`Point2D`. 

499 """ 

500 if not isinstance(start, Point2D): 500 ↛ 501line 500 didn't jump to line 501 because the condition on line 500 was never true

501 ex = TypeError(f"Parameter 'start' is not of type Point2D.") 

502 ex.add_note(f"Got type '{getFullyQualifiedName(start)}'.") 

503 raise ex 

504 if not isinstance(end, Point2D): 504 ↛ 505line 504 didn't jump to line 505 because the condition on line 504 was never true

505 ex = TypeError(f"Parameter 'end' is not of type Point2D.") 

506 ex.add_note(f"Got type '{getFullyQualifiedName(end)}'.") 

507 raise ex 

508 

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

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

511 

512 

513@export 

514class LineSegment2D(Segment2D[Coordinate], Generic[Coordinate]): 

515 """An implementation of a 2D cartesian line segment.""" 

516 

517 @readonly 

518 def Length(self) -> float: 

519 """ 

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

521 

522 :returns: Euclidean distance between start and end point 

523 """ 

524 return sqrt((self.end.x - self.start.x) ** 2 + (self.end.x - self.start.x) ** 2) 

525 

526 def AngleTo(self, other: LineSegment2D[Coordinate]) -> float: 

527 """ 

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

529 

530 :param other: The second line segment. 

531 :returns: The angle in radians. 

532 """ 

533 vectorA = self.ToOffset() 

534 vectorB = other.ToOffset() 

535 scalarProductAB = vectorA.xOffset * vectorB.xOffset + vectorA.yOffset * vectorB.yOffset 

536 

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

538 

539 def ToOffset(self) -> Offset2D[Coordinate]: 

540 """ 

541 Convert this 2D line segment to a 2D-offset. 

542 

543 :returns: 2D-offset as :class:`Offset2D` 

544 """ 

545 return self.end - self.start 

546 

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

548 """ 

549 Convert this 2D line segment to a simple 2-element tuple of 2D-point tuples. 

550 

551 :returns: ``((x1, y1), (x2, y2))`` tuple. 

552 """ 

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

554 

555 def __repr__(self) -> str: 

556 """ 

557 Returns the 2D line segment's string representation. 

558 

559 :returns: The string representation of the 2D line segment. 

560 """ 

561 return f"LineSegment2D({self.start}, {self.end})" 

562 

563 def __str__(self) -> str: 

564 """ 

565 Returns the 2D line segment's string equivalent. 

566 

567 :returns: The string equivalent of the 2D line segment. 

568 """ 

569 return f"({self.start}{self.end})"