Coverage for trimesh/path/entities.py: 90%

240 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-24 04:40 +0000

1""" 

2entities.py 

3-------------- 

4 

5Basic geometric primitives which only store references to 

6vertex indices rather than vertices themselves. 

7""" 

8 

9from copy import deepcopy 

10from logging import getLogger 

11 

12import numpy as np 

13 

14from .. import util 

15from ..util import ABC 

16from .arc import arc_center, discretize_arc 

17from .curve import discretize_bezier, discretize_bspline 

18 

19log = getLogger(__name__) 

20 

21 

22class Entity(ABC): 

23 def __init__( 

24 self, points, closed=None, layer=None, metadata=None, color=None, **kwargs 

25 ): 

26 # points always reference vertex indices and are int 

27 self.points = np.asanyarray(points, dtype=np.int64) 

28 # save explicit closed 

29 if closed is not None: 

30 self.closed = closed 

31 # save the passed layer 

32 if layer is not None: 

33 self.layer = layer 

34 if metadata is not None: 

35 self.metadata.update(metadata) 

36 

37 self._cache = {} 

38 

39 # save the passed color 

40 self.color = color 

41 # save any other kwargs for general use 

42 self.kwargs = kwargs 

43 

44 @property 

45 def metadata(self): 

46 """ 

47 Get any metadata about the entity. 

48 

49 Returns 

50 --------- 

51 metadata : dict 

52 Bag of properties. 

53 """ 

54 if not hasattr(self, "_metadata"): 

55 self._metadata = {} 

56 # note that we don't let a new dict be assigned 

57 return self._metadata 

58 

59 @property 

60 def layer(self): 

61 """ 

62 Set the layer the entity resides on as a shortcut 

63 to putting it in the entity metadata. 

64 

65 Returns 

66 ---------- 

67 layer : any 

68 Hashable layer identifier. 

69 """ 

70 return self.metadata.get("layer") 

71 

72 @layer.setter 

73 def layer(self, value): 

74 """ 

75 Set the current layer of the entity. 

76 

77 Returns 

78 ---------- 

79 layer : any 

80 Hashable layer indicator 

81 """ 

82 self.metadata["layer"] = value 

83 

84 def to_dict(self) -> dict: 

85 """ 

86 Returns a dictionary with all of the information 

87 about the entity. 

88 

89 Returns 

90 ----------- 

91 as_dict : dict 

92 Has keys 'type', 'points', 'closed' 

93 """ 

94 return { 

95 "type": self.__class__.__name__, 

96 "points": self.points.tolist(), 

97 "closed": self.closed, 

98 } 

99 

100 @property 

101 def closed(self): 

102 """ 

103 If the first point is the same as the end point 

104 the entity is closed 

105 

106 Returns 

107 ----------- 

108 closed : bool 

109 Is the entity closed or not? 

110 """ 

111 return len(self.points) > 2 and self.points[0] == self.points[-1] 

112 

113 @property 

114 def nodes(self): 

115 """ 

116 Returns an (n,2) list of nodes, or vertices on the path. 

117 Note that this generic class function assumes that all of the 

118 reference points are on the path which is true for lines and 

119 three point arcs. 

120 

121 If you were to define another class where that wasn't the case 

122 (for example, the control points of a bezier curve), 

123 you would need to implement an entity- specific version of this 

124 function. 

125 

126 The purpose of having a list of nodes is so that they can then be 

127 added as edges to a graph so we can use functions to check 

128 connectivity, extract paths, etc. 

129 

130 The slicing on this function is essentially just tiling points 

131 so the first and last vertices aren't repeated. Example: 

132 

133 self.points = [0,1,2] 

134 returns: [[0,1], [1,2]] 

135 """ 

136 return ( 

137 np.column_stack((self.points, self.points)).reshape(-1)[1:-1].reshape((-1, 2)) 

138 ) 

139 

140 @property 

141 def end_points(self): 

142 """ 

143 Returns the first and last points. Also note that if you 

144 define a new entity class where the first and last vertices 

145 in self.points aren't the endpoints of the curve you need to 

146 implement this function for your class. 

147 

148 Returns 

149 ------------- 

150 ends : (2,) int 

151 Indices of the two end points of the entity 

152 """ 

153 return self.points[[0, -1]] 

154 

155 @property 

156 def is_valid(self): 

157 """ 

158 Is the current entity valid. 

159 

160 Returns 

161 ----------- 

162 valid : bool 

163 Is the current entity well formed 

164 """ 

165 return True 

166 

167 def reverse(self, direction=-1): 

168 """ 

169 Reverse the current entity in place. 

170 

171 Parameters 

172 ---------------- 

173 direction : int 

174 If positive will not touch direction 

175 If negative will reverse self.points 

176 """ 

177 if direction < 0: 

178 self._direction = -1 

179 else: 

180 self._direction = 1 

181 

182 def _orient(self, curve): 

183 """ 

184 Reverse a curve if a flag is set. 

185 

186 Parameters 

187 -------------- 

188 curve : (n, dimension) float 

189 Curve made up of line segments in space 

190 

191 Returns 

192 ------------ 

193 orient : (n, dimension) float 

194 Original curve, but possibly reversed 

195 """ 

196 if hasattr(self, "_direction") and self._direction < 0: 

197 return curve[::-1] 

198 return curve 

199 

200 def bounds(self, vertices): 

201 """ 

202 Return the AABB of the current entity. 

203 

204 Parameters 

205 ----------- 

206 vertices : (n, dimension) float 

207 Vertices in space 

208 

209 Returns 

210 ----------- 

211 bounds : (2, dimension) float 

212 Coordinates of AABB, in (min, max) form 

213 """ 

214 bounds = np.array( 

215 [vertices[self.points].min(axis=0), vertices[self.points].max(axis=0)] 

216 ) 

217 return bounds 

218 

219 def length(self, vertices): 

220 """ 

221 Return the total length of the entity. 

222 

223 Parameters 

224 -------------- 

225 vertices : (n, dimension) float 

226 Vertices in space 

227 

228 Returns 

229 --------- 

230 length : float 

231 Total length of entity 

232 """ 

233 diff = np.diff(self.discrete(vertices), axis=0) ** 2 

234 length = (np.dot(diff, [1] * vertices.shape[1]) ** 0.5).sum() 

235 return length 

236 

237 def explode(self): 

238 """ 

239 Split the entity into multiple entities. 

240 

241 Returns 

242 ------------ 

243 explode : list of Entity 

244 Current entity split into multiple entities. 

245 """ 

246 return [self.copy()] 

247 

248 def copy(self): 

249 """ 

250 Return a copy of the current entity. 

251 

252 Returns 

253 ------------ 

254 copied : Entity 

255 Copy of current entity 

256 """ 

257 copied = deepcopy(self) 

258 # only copy metadata if set 

259 if hasattr(self, "_metadata"): 

260 copied._metadata = deepcopy(self._metadata) 

261 # check for very annoying subtle copy failures 

262 assert id(copied._metadata) != id(self._metadata) 

263 assert id(copied.points) != id(self.points) 

264 return copied 

265 

266 def __hash__(self): 

267 """ 

268 Return a hash that represents the current entity. 

269 

270 Returns 

271 ---------- 

272 hashed : int 

273 Hash of current class name, points, and closed 

274 """ 

275 return hash(self._bytes()) 

276 

277 def _bytes(self): 

278 """ 

279 Get hashable bytes that define the current entity. 

280 

281 Returns 

282 ------------ 

283 data : bytes 

284 Hashable data defining the current entity 

285 """ 

286 # give consistent ordering of points for hash 

287 if self.points[0] > self.points[-1]: 

288 return self.__class__.__name__.encode("utf-8") + self.points.tobytes() 

289 else: 

290 return self.__class__.__name__.encode("utf-8") + self.points[::-1].tobytes() 

291 

292 

293class Text(Entity): 

294 """ 

295 Text to annotate a 2D or 3D path. 

296 """ 

297 

298 def __init__( 

299 self, 

300 origin, 

301 text, 

302 height=None, 

303 vector=None, 

304 normal=None, 

305 align=None, 

306 layer=None, 

307 color=None, 

308 metadata=None, 

309 ): 

310 """ 

311 An entity for text labels. 

312 

313 Parameters 

314 -------------- 

315 origin : int 

316 Index of a single vertex for text origin 

317 text : str 

318 The text to label 

319 height : float or None 

320 The height of text 

321 vector : int or None 

322 An vertex index for which direction text 

323 is written along unitized: vector - origin 

324 normal : int or None 

325 A vertex index for the plane normal: 

326 vector is along unitized: normal - origin 

327 align : (2,) str or None 

328 Where to draw from for [horizontal, vertical]: 

329 'center', 'left', 'right' 

330 """ 

331 # where is text placed 

332 self.origin = origin 

333 # what direction is the text pointing 

334 self.vector = vector 

335 # what is the normal of the text plane 

336 self.normal = normal 

337 # how high is the text entity 

338 self.height = height 

339 # what layer is the entity on 

340 if layer is not None: 

341 self.layer = layer 

342 

343 if metadata is not None: 

344 self.metadata.update(metadata) 

345 

346 # what color is the entity 

347 self.color = color 

348 

349 # None or (2,) str 

350 if align is None: 

351 # if not set make everything centered 

352 align = ["center", "center"] 

353 elif isinstance(align, str): 

354 # if only one is passed set for both 

355 # horizontal and vertical 

356 align = [align, align] 

357 elif len(align) != 2: 

358 # otherwise raise rror 

359 raise ValueError("align must be (2,) str") 

360 

361 self.align = align 

362 

363 # make sure text is a string 

364 if hasattr(text, "decode"): 

365 self.text = text.decode("utf-8") 

366 else: 

367 self.text = str(text) 

368 

369 @property 

370 def origin(self): 

371 """ 

372 The origin point of the text. 

373 

374 Returns 

375 ----------- 

376 origin : int 

377 Index of vertices 

378 """ 

379 return self.points[0] 

380 

381 @origin.setter 

382 def origin(self, value): 

383 value = int(value) 

384 if not hasattr(self, "points") or np.ptp(self.points) == 0: 

385 self.points = np.ones(3, dtype=np.int64) * value 

386 else: 

387 self.points[0] = value 

388 

389 @property 

390 def vector(self): 

391 """ 

392 A point representing the text direction 

393 along the vector: vertices[vector] - vertices[origin] 

394 

395 Returns 

396 ---------- 

397 vector : int 

398 Index of vertex 

399 """ 

400 return self.points[1] 

401 

402 @vector.setter 

403 def vector(self, value): 

404 if value is None: 

405 return 

406 self.points[1] = int(value) 

407 

408 @property 

409 def normal(self): 

410 """ 

411 A point representing the plane normal along the 

412 vector: vertices[normal] - vertices[origin] 

413 

414 Returns 

415 ------------ 

416 normal : int 

417 Index of vertex 

418 """ 

419 return self.points[2] 

420 

421 @normal.setter 

422 def normal(self, value): 

423 if value is None: 

424 return 

425 self.points[2] = int(value) 

426 

427 def plot(self, vertices, show=False): 

428 """ 

429 Plot the text using matplotlib. 

430 

431 Parameters 

432 -------------- 

433 vertices : (n, 2) float 

434 Vertices in space 

435 show : bool 

436 If True, call plt.show() 

437 """ 

438 if vertices.shape[1] != 2: 

439 raise ValueError("only for 2D points!") 

440 

441 import matplotlib.pyplot as plt # noqa 

442 

443 # get rotation angle in degrees 

444 angle = np.degrees(self.angle(vertices)) 

445 

446 # TODO: handle text size better 

447 plt.text( 

448 *vertices[self.origin], 

449 s=self.text, 

450 rotation=angle, 

451 ha=self.align[0], 

452 va=self.align[1], 

453 size=18, 

454 ) 

455 

456 if show: 

457 plt.show() 

458 

459 def angle(self, vertices): 

460 """ 

461 If Text is 2D, get the rotation angle in radians. 

462 

463 Parameters 

464 ----------- 

465 vertices : (n, 2) float 

466 Vertices in space referenced by self.points 

467 

468 Returns 

469 --------- 

470 angle : float 

471 Rotation angle in radians 

472 """ 

473 

474 if vertices.shape[1] != 2: 

475 raise ValueError("angle only valid for 2D points!") 

476 

477 # get the vector from origin 

478 direction = vertices[self.vector] - vertices[self.origin] 

479 # get the rotation angle in radians 

480 angle = np.arctan2(*direction[::-1]) 

481 

482 return angle 

483 

484 def length(self, vertices): 

485 return 0.0 

486 

487 def discrete(self, *args, **kwargs): 

488 return np.array([]) 

489 

490 @property 

491 def closed(self): 

492 return False 

493 

494 @property 

495 def is_valid(self): 

496 return True 

497 

498 @property 

499 def nodes(self): 

500 return np.array([]) 

501 

502 @property 

503 def end_points(self): 

504 return np.array([]) 

505 

506 def _bytes(self): 

507 data = b"".join([b"Text", self.points.tobytes(), self.text.encode("utf-8")]) 

508 return data 

509 

510 

511class Line(Entity): 

512 """ 

513 A line or poly-line entity 

514 """ 

515 

516 def discrete(self, vertices, scale=1.0): 

517 """ 

518 Discretize into a world- space path. 

519 

520 Parameters 

521 ------------ 

522 vertices: (n, dimension) float 

523 Points in space 

524 scale : float 

525 Size of overall scene for numerical comparisons 

526 

527 Returns 

528 ------------- 

529 discrete: (m, dimension) float 

530 Path in space composed of line segments 

531 """ 

532 return self._orient(vertices[self.points]) 

533 

534 @property 

535 def closed(self): 

536 return len(self.points) > 2 and self.points[0] == self.points[-1] 

537 

538 @closed.setter 

539 def closed(self, value: bool): 

540 current = self.points[0] == self.points[-1] 

541 if value and not current: 

542 # case where we've been asked to close the line 

543 # this seems pretty obvious that we should just append the first pointOB 

544 self.points = np.concatenate((self.points, [self.points[0]])) 

545 elif not value and current: 

546 # case where we've been asked to *disconnect* a closed path 

547 log.debug("ignoring `Line.closed = False`") 

548 

549 @property 

550 def is_valid(self): 

551 """ 

552 Is the current entity valid. 

553 

554 Returns 

555 ----------- 

556 valid : bool 

557 Is the current entity well formed 

558 """ 

559 valid = np.any((self.points - self.points[0]) != 0) 

560 return valid 

561 

562 def explode(self): 

563 """ 

564 If the current Line entity consists of multiple line 

565 break it up into n Line entities. 

566 

567 Returns 

568 ---------- 

569 exploded: (n,) Line entities 

570 """ 

571 # copy over the current layer 

572 layer = self.layer 

573 points = ( 

574 np.column_stack((self.points, self.points)).ravel()[1:-1].reshape((-1, 2)) 

575 ) 

576 exploded = [Line(i, layer=layer) for i in points] 

577 return exploded 

578 

579 def _bytes(self): 

580 # give consistent ordering of points for hash 

581 if self.points[0] > self.points[-1]: 

582 return b"Line" + self.points.tobytes() 

583 else: 

584 return b"Line" + self.points[::-1].tobytes() 

585 

586 def to_dict(self) -> dict: 

587 """ 

588 Returns a dictionary with all of the information 

589 about the Line. `closed` is not additional information 

590 for a Line like it is for Arc where the value determines 

591 if it is a partial or complete circle. Rather it is a check 

592 which indicates the first and last points are identical, 

593 and thus should not be included in the export 

594 

595 Returns 

596 ----------- 

597 as_dict 

598 Has keys 'type', 'points' 

599 """ 

600 return { 

601 "type": self.__class__.__name__, 

602 "points": self.points.tolist(), 

603 } 

604 

605 

606class Arc(Entity): 

607 @property 

608 def closed(self): 

609 """ 

610 A boolean flag for whether the arc is closed (a circle) or not. 

611 

612 Returns 

613 ---------- 

614 closed : bool 

615 If set True, Arc will be a closed circle 

616 """ 

617 return getattr(self, "_closed", False) 

618 

619 @closed.setter 

620 def closed(self, value): 

621 """ 

622 Set the Arc to be closed or not, without 

623 changing the control points 

624 

625 Parameters 

626 ------------ 

627 value : bool 

628 Should this Arc be a closed circle or not 

629 """ 

630 self._closed = bool(value) 

631 

632 @property 

633 def is_valid(self): 

634 """ 

635 Is the current Arc entity valid. 

636 

637 Returns 

638 ----------- 

639 valid : bool 

640 Does the current Arc have exactly 3 control points 

641 """ 

642 return len(np.unique(self.points)) == 3 

643 

644 def _bytes(self): 

645 # give consistent ordering of points for hash 

646 order = int(self.points[0] > self.points[-1]) * 2 - 1 

647 return b"Arc" + bytes(self.closed) + self.points[::order].tobytes() 

648 

649 def length(self, vertices): 

650 """ 

651 Return the arc length of the 3-point arc. 

652 

653 Parameter 

654 ---------- 

655 vertices : (n, d) float 

656 Vertices for overall drawing. 

657 

658 Returns 

659 ----------- 

660 length : float 

661 Length of arc. 

662 """ 

663 # find the actual radius and angle span 

664 if self.closed: 

665 # we don't need the angular span as 

666 # it's indicated as a closed circle 

667 fit = self.center(vertices, return_normal=False, return_angle=False) 

668 return np.pi * fit.radius * 4 

669 # get the angular span of the circular arc 

670 fit = self.center(vertices, return_normal=False, return_angle=True) 

671 return fit.span * fit.radius * 2 

672 

673 def discrete(self, vertices, scale=1.0): 

674 """ 

675 Discretize the arc entity into line sections. 

676 

677 Parameters 

678 ------------ 

679 vertices : (n, dimension) float 

680 Points in space 

681 scale : float 

682 Size of overall scene for numerical comparisons 

683 

684 Returns 

685 ------------- 

686 discrete : (m, dimension) float 

687 Path in space made up of line segments 

688 """ 

689 

690 return self._orient( 

691 discretize_arc(vertices[self.points], close=self.closed, scale=scale) 

692 ) 

693 

694 def center(self, vertices, **kwargs): 

695 """ 

696 Return the center information about the arc entity. 

697 

698 Parameters 

699 ------------- 

700 vertices : (n, dimension) float 

701 Vertices in space 

702 

703 Returns 

704 ------------- 

705 info : dict 

706 With keys: 'radius', 'center' 

707 """ 

708 return arc_center(vertices[self.points], **kwargs) 

709 

710 def bounds(self, vertices): 

711 """ 

712 Return the AABB of the arc entity. 

713 

714 Parameters 

715 ----------- 

716 vertices: (n, dimension) float 

717 Vertices in space 

718 

719 Returns 

720 ----------- 

721 bounds : (2, dimension) float 

722 Coordinates of AABB in (min, max) form 

723 """ 

724 if util.is_shape(vertices, (-1, 2)) and self.closed: 

725 # if we have a closed arc (a circle), we can return the actual bounds 

726 # this only works in two dimensions, otherwise this would return the 

727 # AABB of an sphere 

728 info = self.center(vertices, return_normal=False, return_angle=False) 

729 bounds = np.array( 

730 [info.center - info.radius, info.center + info.radius], dtype=np.float64 

731 ) 

732 else: 

733 # since the AABB of a partial arc is hard, approximate 

734 # the bounds by just looking at the discrete values 

735 discrete = self.discrete(vertices) 

736 bounds = np.array( 

737 [discrete.min(axis=0), discrete.max(axis=0)], dtype=np.float64 

738 ) 

739 return bounds 

740 

741 

742class Curve(Entity): 

743 """ 

744 The parent class for all wild curves in space. 

745 """ 

746 

747 @property 

748 def nodes(self): 

749 # a point midway through the curve 

750 mid = self.points[len(self.points) // 2] 

751 return [[self.points[0], mid], [mid, self.points[-1]]] 

752 

753 

754class Bezier(Curve): 

755 """ 

756 An open or closed Bezier curve 

757 """ 

758 

759 def discrete(self, vertices, scale=1.0, count=None): 

760 """ 

761 Discretize the Bezier curve. 

762 

763 Parameters 

764 ------------- 

765 vertices : (n, 2) or (n, 3) float 

766 Points in space 

767 scale : float 

768 Scale of overall drawings (for precision) 

769 count : int 

770 Number of segments to return 

771 

772 Returns 

773 ------------- 

774 discrete : (m, 2) or (m, 3) float 

775 Curve as line segments 

776 """ 

777 return self._orient( 

778 discretize_bezier(vertices[self.points], count=count, scale=scale) 

779 ) 

780 

781 

782class BSpline(Curve): 

783 """ 

784 An open or closed B- Spline. 

785 """ 

786 

787 def __init__(self, points, knots, layer=None, metadata=None, color=None, **kwargs): 

788 self.points = np.asanyarray(points, dtype=np.int64) 

789 self.knots = np.asanyarray(knots, dtype=np.float64) 

790 if layer is not None: 

791 self.layer = layer 

792 if metadata is not None: 

793 self.metadata.update(metadata) 

794 self._cache = {} 

795 self.kwargs = kwargs 

796 self.color = color 

797 

798 def discrete(self, vertices, count=None, scale=1.0): 

799 """ 

800 Discretize the B-Spline curve. 

801 

802 Parameters 

803 ------------- 

804 vertices : (n, 2) or (n, 3) float 

805 Points in space 

806 scale : float 

807 Scale of overall drawings (for precision) 

808 count : int 

809 Number of segments to return 

810 

811 Returns 

812 ------------- 

813 discrete : (m, 2) or (m, 3) float 

814 Curve as line segments 

815 """ 

816 discrete = discretize_bspline( 

817 control=vertices[self.points], knots=self.knots, count=count, scale=scale 

818 ) 

819 return self._orient(discrete) 

820 

821 def _bytes(self): 

822 # give consistent ordering of points for hash 

823 if self.points[0] > self.points[-1]: 

824 return b"BSpline" + self.knots.tobytes() + self.points.tobytes() 

825 else: 

826 return b"BSpline" + self.knots[::-1].tobytes() + self.points[::-1].tobytes() 

827 

828 def to_dict(self) -> dict: 

829 """ 

830 Returns a dictionary with all of the information 

831 about the entity. 

832 """ 

833 return { 

834 "type": self.__class__.__name__, 

835 "points": self.points.tolist(), 

836 "knots": self.knots.tolist(), 

837 "closed": self.closed, 

838 }