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

203 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 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("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 self.x = x 

80 self.y = y 

81 

82 def Copy(self) -> Self: 

83 """ 

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

85 

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

87 

88 .. seealso:: 

89 

90 :meth:`+ operator <__add__>` 

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

92 :meth:`- operator <__sub__>` 

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

94 """ 

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

96 

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

98 """ 

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

100 

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

102 """ 

103 return self.x, self.y 

104 

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

106 """ 

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

108 

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

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

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

112 """ 

113 if isinstance(other, Offset2D): 

114 return self.__class__( 

115 self.x + other.xOffset, 

116 self.y + other.yOffset 

117 ) 

118 elif isinstance(other, tuple): 

119 return self.__class__( 

120 self.x + other[0], 

121 self.y + other[1] 

122 ) 

123 else: 

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

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

126 raise ex 

127 

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

129 """ 

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

131 

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

133 :returns: This 2D-point. 

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

135 """ 

136 if isinstance(other, Offset2D): 

137 self.x += other.xOffset 

138 self.y += other.yOffset 

139 elif isinstance(other, tuple): 

140 self.x += other[0] 

141 self.y += other[1] 

142 else: 

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

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

145 raise ex 

146 

147 return self 

148 

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

150 """ 

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

152 

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

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

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

156 """ 

157 if isinstance(other, Point2D): 

158 return Offset2D( 

159 self.x - other.x, 

160 self.y - other.y 

161 ) 

162 else: 

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

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

165 raise ex 

166 

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

168 """ 

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

170 

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

172 :returns: This 2D-point. 

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

174 """ 

175 if isinstance(other, Offset2D): 

176 self.x -= other.xOffset 

177 self.y -= other.yOffset 

178 elif isinstance(other, tuple): 

179 self.x -= other[0] 

180 self.y -= other[1] 

181 else: 

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

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

184 raise ex 

185 

186 return self 

187 

188 def __repr__(self) -> str: 

189 """ 

190 Returns the 2D point's string representation. 

191 

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

193 """ 

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

195 

196 def __str__(self) -> str: 

197 """ 

198 Returns the 2D point's string equivalent. 

199 

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

201 """ 

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

203 

204 

205@export 

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

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

208 

209 def __init__(self) -> None: 

210 """ 

211 Initializes a 2-dimensional origin. 

212 """ 

213 super().__init__(0, 0) 

214 

215 def Copy(self) -> Self: 

216 """ 

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

218 

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

220 """ 

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

222 

223 def __repr__(self) -> str: 

224 """ 

225 Returns the 2D origin's string representation. 

226 

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

228 """ 

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

230 

231 

232@export 

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

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

235 

236 xOffset: Coordinate #: The x-direction offset 

237 yOffset: Coordinate #: The y-direction offset 

238 

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

240 """ 

241 Initializes a 2-dimensional offset. 

242 

243 :param xOffset: x-direction offset. 

244 :param yOffset: y-direction offset. 

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

246 """ 

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

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

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

250 raise ex 

251 

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

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

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

255 raise ex 

256 

257 self.xOffset = xOffset 

258 self.yOffset = yOffset 

259 

260 def Copy(self) -> Self: 

261 """ 

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

263 

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

265 

266 .. seealso:: 

267 

268 :meth:`+ operator <__add__>` 

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

270 :meth:`- operator <__sub__>` 

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

272 """ 

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

274 

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

276 """ 

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

278 

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

280 """ 

281 return self.xOffset, self.yOffset 

282 

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

284 """ 

285 Compare two 2D-offsets for equality. 

286 

287 :param other: Parameter to compare against. 

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

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

290 """ 

291 if isinstance(other, Offset2D): 

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

293 elif isinstance(other, tuple): 

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

295 else: 

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

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

298 raise ex 

299 

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

301 """ 

302 Compare two 2D-offsets for inequality. 

303 

304 :param other: Parameter to compare against. 

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

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

307 """ 

308 return not self.__eq__(other) 

309 

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

311 """ 

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

313 

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

315 """ 

316 return self.__class__( 

317 -self.xOffset, 

318 -self.yOffset 

319 ) 

320 

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

322 """ 

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

324 

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

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

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

328 """ 

329 if isinstance(other, Offset2D): 

330 return self.__class__( 

331 self.xOffset + other.xOffset, 

332 self.yOffset + other.yOffset 

333 ) 

334 elif isinstance(other, tuple): 

335 return self.__class__( 

336 self.xOffset + other[0], 

337 self.yOffset + other[1] 

338 ) 

339 else: 

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

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

342 raise ex 

343 

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

345 """ 

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

347 

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

349 :returns: This 2D-point. 

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

351 """ 

352 if isinstance(other, Offset2D): 

353 self.xOffset += other.xOffset 

354 self.yOffset += other.yOffset 

355 elif isinstance(other, tuple): 

356 self.xOffset += other[0] 

357 self.yOffset += other[1] 

358 else: 

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

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

361 raise ex 

362 

363 return self 

364 

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

366 """ 

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

368 

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

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

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

372 """ 

373 if isinstance(other, Offset2D): 

374 return self.__class__( 

375 self.xOffset - other.xOffset, 

376 self.yOffset - other.yOffset 

377 ) 

378 elif isinstance(other, tuple): 

379 return self.__class__( 

380 self.xOffset - other[0], 

381 self.yOffset - other[1] 

382 ) 

383 else: 

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

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

386 raise ex 

387 

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

389 """ 

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

391 

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

393 :returns: This 2D-point. 

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

395 """ 

396 if isinstance(other, Offset2D): 

397 self.xOffset -= other.xOffset 

398 self.yOffset -= other.yOffset 

399 elif isinstance(other, tuple): 

400 self.xOffset -= other[0] 

401 self.yOffset -= other[1] 

402 else: 

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

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

405 raise ex 

406 

407 return self 

408 

409 def __repr__(self) -> str: 

410 """ 

411 Returns the 2D offset's string representation. 

412 

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

414 """ 

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

416 

417 def __str__(self) -> str: 

418 """ 

419 Returns the 2D offset's string equivalent. 

420 

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

422 """ 

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

424 

425 

426@export 

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

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

429 

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

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

432 

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

434 """ 

435 Initializes a 2-dimensional size. 

436 

437 :param width: width in x-direction. 

438 :param height: height in y-direction. 

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

440 """ 

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

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

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

444 raise ex 

445 

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

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

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

449 raise ex 

450 

451 self.width = width 

452 self.height = height 

453 

454 def Copy(self) -> Self: 

455 """ 

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

457 

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

459 """ 

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

461 

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

463 """ 

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

465 

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

467 """ 

468 return self.width, self.height 

469 

470 def __repr__(self) -> str: 

471 """ 

472 Returns the 2D size's string representation. 

473 

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

475 """ 

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

477 

478 def __str__(self) -> str: 

479 """ 

480 Returns the 2D size's string equivalent. 

481 

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

483 """ 

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

485 

486 

487@export 

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

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

490 

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

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

493 

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

495 """ 

496 Initializes a 2-dimensional segment. 

497 

498 :param start: Start point of the segment. 

499 :param end: End point of the segment. 

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

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

502 """ 

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

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

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

506 raise ex 

507 

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

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

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

511 raise ex 

512 

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

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

515 

516 

517@export 

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

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

520 

521 @readonly 

522 def Length(self) -> float: 

523 """ 

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

525 

526 :returns: Euclidean distance between start and end point 

527 """ 

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

529 

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

531 """ 

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

533 

534 :param other: The second line segment. 

535 :returns: The angle in radians. 

536 """ 

537 vectorA = self.ToOffset() 

538 vectorB = other.ToOffset() 

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

540 

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

542 

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

544 """ 

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

546 

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

548 """ 

549 return self.end - self.start 

550 

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

552 """ 

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

554 

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

556 """ 

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

558 

559 def __repr__(self) -> str: 

560 """ 

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

562 

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

564 """ 

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

566 

567 def __str__(self) -> str: 

568 """ 

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

570 

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

572 """ 

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