Coverage for trimesh/path/path.py: 87%

506 statements  

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

1""" 

2path.py 

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

4 

5A module designed to work with vector paths such as 

6those stored in a DXF or SVG file. 

7""" 

8 

9import collections 

10import warnings 

11from copy import deepcopy 

12from hashlib import sha256 

13 

14import numpy as np 

15 

16from .. import ( 

17 bounds, 

18 caching, 

19 comparison, 

20 convex, 

21 exceptions, 

22 grouping, 

23 parent, 

24 units, 

25 util, 

26) 

27from .. import transformations as tf 

28from ..caching import cache_decorator 

29from ..constants import log 

30from ..constants import tol_path as tol 

31from ..geometry import plane_transform 

32from ..points import plane_fit 

33from ..typed import ( 

34 ArrayLike, 

35 Iterable, 

36 Mapping, 

37 NDArray, 

38 Self, 

39 float64, 

40) 

41from ..visual import to_rgba 

42from . import ( 

43 creation, # NOQA 

44 raster, 

45 segments, # NOQA 

46 simplify, 

47 traversal, 

48) 

49from .entities import Entity 

50from .exchange.export import export_path 

51from .util import concatenate 

52 

53# now import things which require non-minimal install of Trimesh 

54# create a dummy module which will raise the ImportError 

55# or other exception only when someone tries to use that function 

56try: 

57 from . import repair 

58except BaseException as E: 

59 repair = exceptions.ExceptionWrapper(E) 

60try: 

61 from . import polygons 

62except BaseException as E: 

63 polygons = exceptions.ExceptionWrapper(E) 

64try: 

65 from scipy.spatial import cKDTree 

66except BaseException as E: 

67 cKDTree = exceptions.ExceptionWrapper(E) 

68try: 

69 from shapely.geometry import Polygon 

70except BaseException as E: 

71 Polygon = exceptions.ExceptionWrapper(E) 

72 

73try: 

74 import networkx as nx 

75except BaseException as E: 

76 nx = exceptions.ExceptionWrapper(E) 

77 

78 

79class Path(parent.Geometry): 

80 """ 

81 A Path object consists of vertices and entities. Vertices 

82 are a simple (n, dimension) float array of points in space. 

83 

84 Entities are a list of objects representing geometric 

85 primitives, such as Lines, Arcs, BSpline, etc. All entities 

86 reference vertices by index, so any transform applied to the 

87 simple vertex array is applied to the entity. 

88 """ 

89 

90 def __init__( 

91 self, 

92 entities: ArrayLike | Iterable[Entity] | None = None, 

93 vertices: ArrayLike | None = None, 

94 metadata: Mapping | None = None, 

95 process: bool = True, 

96 colors: ArrayLike | None = None, 

97 vertex_attributes: Mapping | None = None, 

98 **kwargs, 

99 ): 

100 """ 

101 Instantiate a path object. 

102 

103 Parameters 

104 ----------- 

105 entities : (m,) trimesh.path.entities.Entity 

106 Contains geometric entities 

107 vertices : (n, dimension) float 

108 The vertices referenced by entities 

109 metadata : dict 

110 Any metadata about the path 

111 process : bool 

112 Run simple cleanup or not 

113 colors 

114 Set any per-entity colors. 

115 vertex_attributes 

116 Set any per-vertex array data. 

117 """ 

118 

119 self.entities = entities 

120 self.vertices = vertices 

121 

122 # assign each color to each entity 

123 self.colors = colors 

124 # collect metadata 

125 self.metadata = {} 

126 if isinstance(metadata, dict): 

127 self.metadata.update(metadata) 

128 

129 self.vertex_attributes = {} 

130 if vertex_attributes is not None: 

131 self.vertex_attributes.update(vertex_attributes) 

132 

133 # cache will dump whenever self.crc changes 

134 self._cache = caching.Cache(id_function=self.__hash__) 

135 

136 if process: 

137 # if our input had disconnected but identical points 

138 # pretty much nothing will work if vertices aren't merged properly 

139 self.merge_vertices() 

140 

141 def __repr__(self): 

142 """ 

143 Print a quick summary of the number of vertices and entities. 

144 """ 

145 return f"<trimesh.{type(self).__name__}(vertices.shape={self.vertices.shape}, len(entities)={len(self.entities)})>" 

146 

147 def process(self) -> Self: 

148 """ 

149 Apply basic cleaning functions to the Path object in-place. 

150 """ 

151 with self._cache: 

152 self.merge_vertices() 

153 self.remove_duplicate_entities() 

154 self.remove_unreferenced_vertices() 

155 return self 

156 

157 @property 

158 def colors(self) -> NDArray | None: 

159 """ 

160 Colors are stored per-entity. 

161 

162 Returns 

163 ------------ 

164 colors : (len(entities), 4) uint8 

165 RGBA colors for each entity 

166 """ 

167 # start with default colors 

168 raw = [e.color for e in self.entities] 

169 if not any(c is not None for c in raw): 

170 return None 

171 

172 colors = np.array([to_rgba(c) for c in raw]) 

173 # don't allow parts of the color array to be written 

174 colors.flags["WRITEABLE"] = False 

175 return colors 

176 

177 @colors.setter 

178 def colors(self, values: ArrayLike | None): 

179 """ 

180 Set the color for every entity in the Path. 

181 

182 Parameters 

183 ------------ 

184 values : (len(entities), 4) uint8 

185 Color of each entity 

186 """ 

187 # if not set return 

188 if values is None: 

189 return 

190 # make sure colors are RGBA 

191 colors = to_rgba(values) 

192 if len(colors) != len(self.entities): 

193 raise ValueError("colors must be per-entity!") 

194 # otherwise assign each color to the entity 

195 for c, e in zip(colors, self.entities): 

196 e.color = c 

197 

198 @property 

199 def vertices(self) -> NDArray[float64]: 

200 return self._vertices 

201 

202 @vertices.setter 

203 def vertices(self, values: ArrayLike | None): 

204 if values is None: 

205 self._vertices = caching.tracked_array([], dtype=np.float64) 

206 else: 

207 self._vertices = caching.tracked_array(values, dtype=np.float64) 

208 

209 @property 

210 def entities(self): 

211 """ 

212 The actual entities making up the path. 

213 

214 Returns 

215 ----------- 

216 entities : (n,) trimesh.path.entities.Entity 

217 Entities such as Line, Arc, or BSpline curves 

218 """ 

219 return self._entities 

220 

221 @entities.setter 

222 def entities(self, values): 

223 if values is None: 

224 self._entities = np.array([]) 

225 else: 

226 self._entities = np.asanyarray(values) 

227 

228 @property 

229 def layers(self): 

230 """ 

231 Get a list of the layer for every entity. 

232 

233 Returns 

234 --------- 

235 layers : (len(entities), ) any 

236 Whatever is stored in each `entity.layer` 

237 """ 

238 # layer is a required meta-property for entities 

239 return [e.layer for e in self.entities] 

240 

241 def __hash__(self): 

242 """ 

243 A hash of the current vertices and entities. 

244 

245 Returns 

246 ------------ 

247 hash : long int 

248 Appended hashes 

249 """ 

250 # get the hash of the trackedarray vertices 

251 hashable = [hex(self.vertices.__hash__()).encode("utf-8")] 

252 # get the bytes for each entity 

253 hashable.extend(e._bytes() for e in self.entities) 

254 # hash the combined result 

255 return caching.hash_fast(b"".join(hashable)) 

256 

257 @cache_decorator 

258 def identifier_hash(self): 

259 """ 

260 Return a hash of the identifier. 

261 

262 Returns 

263 ---------- 

264 hashed : (64,) str 

265 SHA256 hash of the identifier vector. 

266 """ 

267 as_int = (self.identifier * 1e4).astype(np.int64) 

268 return sha256(as_int.tobytes(order="C")).hexdigest() 

269 

270 @cache_decorator 

271 def paths(self): 

272 """ 

273 Sequence of closed paths, encoded by entity index. 

274 

275 Returns 

276 --------- 

277 paths : (n,) sequence of (*,) int 

278 Referencing self.entities 

279 """ 

280 paths = traversal.closed_paths(self.entities, self.vertices) 

281 return paths 

282 

283 @cache_decorator 

284 def dangling(self): 

285 """ 

286 List of entities that aren't included in a closed path 

287 

288 Returns 

289 ---------- 

290 dangling : (n,) int 

291 Index of self.entities 

292 """ 

293 if len(self.paths) == 0: 

294 return np.arange(len(self.entities)) 

295 

296 return np.setdiff1d(np.arange(len(self.entities)), np.hstack(self.paths)) 

297 

298 @cache_decorator 

299 def kdtree(self): 

300 """ 

301 A KDTree object holding the vertices of the path. 

302 

303 Returns 

304 ---------- 

305 kdtree : scipy.spatial.cKDTree 

306 Object holding self.vertices 

307 """ 

308 kdtree = cKDTree(self.vertices.view(np.ndarray)) 

309 return kdtree 

310 

311 @cache_decorator 

312 def length(self): 

313 """ 

314 The total discretized length of every entity. 

315 

316 Returns 

317 -------- 

318 length : float 

319 Summed length of every entity 

320 """ 

321 length = float(sum(i.length(self.vertices) for i in self.entities)) 

322 return length 

323 

324 @cache_decorator 

325 def bounds(self): 

326 """ 

327 Return the axis aligned bounding box of the current path. 

328 

329 Returns 

330 ---------- 

331 bounds : (2, dimension) float 

332 AABB with (min, max) coordinates 

333 """ 

334 # get the exact bounds of each entity 

335 # some entities (aka 3- point Arc) have bounds that can't 

336 # be generated from just bound box of vertices 

337 

338 points = np.array( 

339 [e.bounds(self.vertices) for e in self.entities], dtype=np.float64 

340 ) 

341 

342 # flatten bound extrema into (n, dimension) array 

343 points = points.reshape((-1, self.vertices.shape[1])) 

344 # get the max and min of all bounds 

345 return np.array([points.min(axis=0), points.max(axis=0)], dtype=np.float64) 

346 

347 @cache_decorator 

348 def centroid(self) -> NDArray[float64]: 

349 """ 

350 Return the centroid of axis aligned bounding box enclosing 

351 all entities of the path object. 

352 

353 Returns 

354 ----------- 

355 centroid : (d,) float 

356 Approximate centroid of the path 

357 """ 

358 return self.bounds.mean(axis=0) 

359 

360 @property 

361 def extents(self) -> NDArray[float64]: 

362 """ 

363 The size of the axis aligned bounding box. 

364 

365 Returns 

366 --------- 

367 extents : (dimension,) float 

368 Edge length of AABB 

369 """ 

370 return np.ptp(self.bounds, axis=0) 

371 

372 def convert_units(self, desired: str, guess: bool = False): 

373 """ 

374 Convert the units of the current drawing in place. 

375 

376 Parameters 

377 ----------- 

378 desired : str 

379 Unit system to convert to 

380 guess : bool 

381 If True will attempt to guess units 

382 """ 

383 units._convert_units(self, desired=desired, guess=guess) 

384 

385 def explode(self): 

386 """ 

387 Turn every multi- segment entity into single segment 

388 entities in- place. 

389 """ 

390 new_entities = [] 

391 for entity in self.entities: 

392 # explode into multiple entities 

393 new_entities.extend(entity.explode()) 

394 # avoid setter and assign new entities 

395 self._entities = np.array(new_entities) 

396 # explicitly clear cache 

397 self._cache.clear() 

398 

399 def fill_gaps(self, distance=0.025): 

400 """ 

401 Find vertices without degree 2 and try to connect to 

402 other vertices. Operations are done in-place. 

403 

404 Parameters 

405 ---------- 

406 distance : float 

407 Connect vertices up to this distance 

408 """ 

409 repair.fill_gaps(self, distance=distance) 

410 

411 @property 

412 def is_closed(self): 

413 """ 

414 Are all entities connected to other entities. 

415 

416 Returns 

417 ----------- 

418 closed : bool 

419 Every entity is connected at its ends 

420 """ 

421 closed = all(i == 2 for i in dict(self.vertex_graph.degree()).values()) 

422 

423 return closed 

424 

425 @property 

426 def is_empty(self): 

427 """ 

428 Are any entities defined for the current path. 

429 

430 Returns 

431 ---------- 

432 empty : bool 

433 True if no entities are defined 

434 """ 

435 return len(self.entities) == 0 

436 

437 @cache_decorator 

438 def vertex_graph(self): 

439 """ 

440 Return a networkx.Graph object for the entity connectivity 

441 

442 graph : networkx.Graph 

443 Holds vertex indexes 

444 """ 

445 graph, _closed = traversal.vertex_graph(self.entities) 

446 return graph 

447 

448 @cache_decorator 

449 def vertex_nodes(self): 

450 """ 

451 Get a list of which vertex indices are nodes, 

452 which are either endpoints or points where the 

453 entity makes a direction change. 

454 

455 Returns 

456 -------------- 

457 nodes : (n, 2) int 

458 Indexes of self.vertices which are nodes 

459 """ 

460 nodes = np.vstack([e.nodes for e in self.entities]) 

461 return nodes 

462 

463 def apply_transform(self, transform: ArrayLike) -> Self: 

464 """ 

465 Apply a transformation matrix to the current path in- place 

466 

467 Parameters 

468 ----------- 

469 transform : (d+1, d+1) float 

470 Homogeneous transformations for vertices 

471 """ 

472 dimension = self.vertices.shape[1] 

473 transform = np.asanyarray(transform, dtype=np.float64) 

474 

475 if transform.shape != (dimension + 1, dimension + 1): 

476 raise ValueError("transform is incorrect shape!") 

477 elif np.abs(transform - np.eye(dimension + 1)).max() < 1e-8: 

478 # if we've been passed an identity matrix do nothing 

479 return self 

480 

481 # make sure cache is up to date 

482 self._cache.verify() 

483 # new cache to transfer items 

484 cache = {} 

485 # apply transform to discretized paths 

486 if "discrete" in self._cache.cache: 

487 cache["discrete"] = [ 

488 tf.transform_points(d, matrix=transform) for d in self.discrete 

489 ] 

490 

491 # things we can just straight up copy 

492 # as they are topological not geometric 

493 for key in [ 

494 "root", 

495 "paths", 

496 "path_valid", 

497 "dangling", 

498 "vertex_graph", 

499 "enclosure", 

500 "enclosure_shell", 

501 "enclosure_directed", 

502 ]: 

503 # if they're in cache save them from the purge 

504 if key in self._cache.cache: 

505 cache[key] = self._cache.cache[key] 

506 

507 # transform vertices in place 

508 self.vertices = tf.transform_points(self.vertices, matrix=transform) 

509 # explicitly clear the cache 

510 self._cache.clear() 

511 self._cache.id_set() 

512 

513 # populate the things we wangled 

514 self._cache.cache.update(cache) 

515 return self 

516 

517 def apply_layer(self, name: str) -> None: 

518 """ 

519 Apply a layer name to every entity in the path. 

520 

521 Parameters 

522 ------------ 

523 name : str 

524 Apply layer name to every entity 

525 """ 

526 for e in self.entities: 

527 e.layer = name 

528 

529 def rezero(self): 

530 """ 

531 Translate so that every vertex is positive in the current 

532 mesh is positive. 

533 

534 Returns 

535 ----------- 

536 matrix : (dimension + 1, dimension + 1) float 

537 Homogeneous transformations that was applied 

538 to the current Path object. 

539 """ 

540 # transform to the lower left corner 

541 matrix = tf.translation_matrix(-self.bounds[0]) 

542 # cleanly apply trransformation matrix 

543 self.apply_transform(matrix) 

544 

545 return matrix 

546 

547 def merge_vertices(self, digits=None): 

548 """ 

549 Merges vertices which are identical and replace references 

550 by altering `self.entities` and `self.vertices` 

551 

552 Parameters 

553 -------------- 

554 digits : None, or int 

555 How many digits to consider when merging vertices 

556 """ 

557 if len(self.vertices) == 0: 

558 return 

559 if digits is None: 

560 digits = util.decimal_to_digits(tol.merge * self.scale, min_digits=1) 

561 

562 unique, inverse = grouping.unique_rows(self.vertices, digits=digits) 

563 self.vertices = self.vertices[unique] 

564 self.vertex_attributes = { 

565 key: np.array(value)[unique] for key, value in self.vertex_attributes.items() 

566 } 

567 

568 entities_ok = np.ones(len(self.entities), dtype=bool) 

569 

570 for index, entity in enumerate(self.entities): 

571 # what kind of entity are we dealing with 

572 kind = type(entity).__name__ 

573 

574 # entities that don't need runs merged 

575 # don't screw up control- point- knot relationship 

576 if kind in "BSpline Bezier Text": 

577 entity.points = inverse[entity.points] 

578 continue 

579 # if we merged duplicate vertices, the entity may 

580 # have multiple references to the same vertex 

581 points = grouping.merge_runs(inverse[entity.points]) 

582 # if there are three points and two are identical fix it 

583 if kind == "Line": 

584 if len(points) == 3 and points[0] == points[-1]: 

585 points = points[:2] 

586 elif len(points) < 2: 

587 # lines need two or more vertices 

588 entities_ok[index] = False 

589 elif kind == "Arc" and len(points) != 3: 

590 # three point arcs need three points 

591 entities_ok[index] = False 

592 

593 # store points in entity 

594 entity.points = points 

595 

596 # remove degenerate entities 

597 self.entities = self.entities[entities_ok] 

598 

599 def replace_vertex_references(self, mask): 

600 """ 

601 Replace the vertex index references in every entity. 

602 

603 Parameters 

604 ------------ 

605 mask : (len(self.vertices), ) int 

606 Contains new vertex indexes 

607 

608 Notes 

609 ------------ 

610 entity.points in self.entities 

611 Replaced by mask[entity.points] 

612 """ 

613 for entity in self.entities: 

614 entity.points = mask[entity.points] 

615 

616 def remove_entities(self, entity_ids): 

617 """ 

618 Remove entities by index. 

619 

620 Parameters 

621 ----------- 

622 entity_ids : (n,) int 

623 Indexes of self.entities to remove 

624 """ 

625 if len(entity_ids) == 0: 

626 return 

627 keep = np.ones(len(self.entities), dtype=bool) 

628 keep[entity_ids] = False 

629 self.entities = self.entities[keep] 

630 

631 def remove_invalid(self): 

632 """ 

633 Remove entities which declare themselves invalid 

634 

635 Notes 

636 ---------- 

637 self.entities: shortened 

638 """ 

639 valid = np.array([i.is_valid for i in self.entities], dtype=bool) 

640 self.entities = self.entities[valid] 

641 

642 def remove_duplicate_entities(self): 

643 """ 

644 Remove entities that are duplicated 

645 

646 Notes 

647 ------- 

648 self.entities: length same or shorter 

649 """ 

650 entity_hashes = np.array([hash(i) for i in self.entities]) 

651 unique, _inverse = grouping.unique_rows(entity_hashes) 

652 if len(unique) != len(self.entities): 

653 self.entities = self.entities[unique] 

654 

655 @cache_decorator 

656 def referenced_vertices(self): 

657 """ 

658 Which vertices are referenced by an entity. 

659 

660 Returns 

661 ----------- 

662 referenced_vertices: (n,) int, indexes of self.vertices 

663 """ 

664 # no entities no reference 

665 if len(self.entities) == 0: 

666 return np.array([], dtype=np.int64) 

667 return np.unique( 

668 np.concatenate([e.points for e in self.entities]).astype(np.int64) 

669 ) 

670 

671 def remove_unreferenced_vertices(self): 

672 """ 

673 Removes all vertices which aren't used by an entity. 

674 

675 Notes 

676 --------- 

677 self.vertices : reordered and shortened 

678 self.entities : entity.points references updated 

679 """ 

680 

681 unique = self.referenced_vertices 

682 

683 mask = np.ones(len(self.vertices), dtype=np.int64) * -1 

684 mask[unique] = np.arange(len(unique), dtype=np.int64) 

685 

686 self.replace_vertex_references(mask=mask) 

687 self.vertices = self.vertices[unique] 

688 

689 @cache_decorator 

690 def discrete(self) -> list[NDArray[float64]]: 

691 """ 

692 A sequence of connected vertices in space, corresponding to 

693 self.paths. 

694 

695 Returns 

696 --------- 

697 discrete : (len(self.paths),) 

698 A sequence of (m*, dimension) float 

699 """ 

700 # avoid cache hits in the loop 

701 scale = self.scale 

702 entities = self.entities 

703 vertices = self.vertices 

704 

705 # discretize each path 

706 return [ 

707 traversal.discretize_path( 

708 entities=entities, vertices=vertices, path=path, scale=scale 

709 ) 

710 for path in self.paths 

711 ] 

712 

713 def export(self, file_obj=None, file_type=None, **kwargs): 

714 """ 

715 Export the path to a file object or return data. 

716 

717 Parameters 

718 --------------- 

719 file_obj : None, str, or file object 

720 File object or string to export to 

721 file_type : None or str 

722 Type of file: dxf, dict, svg 

723 

724 Returns 

725 --------------- 

726 exported : bytes or str 

727 Exported as specified type 

728 """ 

729 return export_path(self, file_type=file_type, file_obj=file_obj, **kwargs) 

730 

731 def to_dict(self) -> dict: 

732 return self.export(file_type="dict") 

733 

734 def copy(self, layers: str | None | Iterable[str | None] = None): 

735 """ 

736 Get a copy of the current mesh 

737 

738 Parameters 

739 ------------ 

740 layers 

741 If passed an iterable of layer names which will 

742 only include those layers in the copy of the path. 

743 

744 Returns 

745 --------- 

746 copied : Path object 

747 Copy of self 

748 """ 

749 

750 metadata = {} 

751 # grab all the keys into a list so if something is added 

752 # in another thread it probably doesn't stomp on our loop 

753 for key in list(self.metadata.keys()): 

754 try: 

755 metadata[key] = deepcopy(self.metadata[key]) 

756 except RuntimeError: 

757 # multiple threads 

758 log.warning(f"key {key} changed during copy") 

759 

760 if layers is not None: 

761 # get layers as a set for in-loop checks 

762 if isinstance(layers, str): 

763 # the `set` constructor would split in to char 

764 layers = {layers} 

765 else: 

766 # a set of strings 

767 layers = set(layers) 

768 # cherry pick the entities we want 

769 entities = [e for e in self.entities if e.layer in layers] 

770 else: 

771 entities = self.entities 

772 

773 # copy the core data 

774 copied = type(self)( 

775 entities=deepcopy(entities), 

776 vertices=deepcopy(self.vertices), 

777 metadata=metadata, 

778 process=False, 

779 ) 

780 

781 # skip the cache wangling for a subset copy 

782 if layers is not None: 

783 return copied 

784 

785 cache = {} 

786 # try to copy the cache over to the new object 

787 try: 

788 # save dict keys before doing slow iteration 

789 keys = list(self._cache.cache.keys()) 

790 # run through each key and copy into new cache 

791 for k in keys: 

792 cache[k] = deepcopy(self._cache.cache[k]) 

793 except RuntimeError: 

794 # if we have multiple threads this may error and is NBD 

795 log.debug("unable to copy cache") 

796 except BaseException: 

797 # catch and log errors we weren't expecting 

798 log.error("unable to copy cache", exc_info=True) 

799 copied._cache.cache = cache 

800 copied._cache.id_set() 

801 

802 return copied 

803 

804 def scene(self): 

805 """ 

806 Get a scene object containing the current Path3D object. 

807 

808 Returns 

809 -------- 

810 scene: trimesh.scene.Scene object containing current path 

811 """ 

812 from ..scene import Scene 

813 

814 scene = Scene(self) 

815 return scene 

816 

817 def __add__(self, other): 

818 """ 

819 Concatenate two Path objects by appending vertices and 

820 reindexing point references. 

821 

822 Parameters 

823 ----------- 

824 other: Path object 

825 

826 Returns 

827 ----------- 

828 concat: Path object, appended from self and other 

829 """ 

830 concat = concatenate([self, other]) 

831 return concat 

832 

833 

834class Path3D(Path): 

835 """ 

836 Hold multiple vector curves (lines, arcs, splines, etc) in 3D. 

837 """ 

838 

839 def to_planar(self, *args, **kwargs): 

840 """ 

841 DEPRECATED: replace `path.to_planar`->`path.to_2D), removal 1/1/2026 

842 """ 

843 warnings.warn( 

844 "DEPRECATED: replace `path.to_planar`->`path.to_2D), removal 1/1/2026", 

845 category=DeprecationWarning, 

846 stacklevel=2, 

847 ) 

848 return self.to_2D(*args, **kwargs) 

849 

850 def to_2D( 

851 self, 

852 to_2D: ArrayLike | None = None, 

853 normal: ArrayLike | None = None, 

854 check: bool = True, 

855 ) -> tuple["Path2D", NDArray[float64]]: 

856 """ 

857 Check to see if current vectors are all coplanar. 

858 

859 If they are, return a Path2D and a transform which will 

860 transform the 2D representation back into 3 dimensions 

861 

862 Parameters 

863 ----------- 

864 to_2D : (4, 4) float 

865 Homogeneous transformation matrix to apply, 

866 if not passed a plane will be fitted to vertices. 

867 normal : (3,) float or None 

868 Normal of direction of plane to use. 

869 check 

870 Raise a ValueError if points aren't coplanar. 

871 

872 Returns 

873 ----------- 

874 planar 

875 Current path transformed onto plane 

876 to_3D : (4, 4) float 

877 Homeogenous transformations to move planar 

878 back into the original 3D frame. 

879 """ 

880 # which vertices are actually referenced 

881 referenced = self.referenced_vertices 

882 # if nothing is referenced return an empty path 

883 if len(referenced) == 0: 

884 return Path2D(), np.eye(4) 

885 

886 # support (n, 2) and (n, 3) vertices here 

887 dim = self.vertices.shape[1] 

888 

889 # already flat 

890 if dim == 2: 

891 to_2D = np.eye(4) 

892 elif dim != 3: 

893 raise ValueError(f"vertices are `{dim}D != 2D | 3D`!") 

894 

895 # no explicit transform passed 

896 if to_2D is None: 

897 # fit a plane to our vertices 

898 C, N = plane_fit(self.vertices[referenced]) 

899 # apply the normal sign hint 

900 if normal is not None: 

901 # make sure normal is a 3D vector 

902 normal = np.array(normal, dtype=np.float64).reshape(3) 

903 # apply the sign from the passed normal 

904 N *= np.sign(np.dot(N, normal)) 

905 # create a transform from fit plane to XY 

906 to_2D = plane_transform(origin=C, normal=N) 

907 

908 # make sure we've extracted a transform 

909 to_2D = np.array(to_2D, dtype=np.float64) 

910 if to_2D.shape != (4, 4): 

911 raise ValueError("unable to create transform!") 

912 

913 if dim == 3: 

914 # transform all vertices to 2D plane 

915 flat = tf.transform_points(self.vertices, to_2D) 

916 # Z values of vertices which are referenced 

917 heights = flat[referenced][:, 2] 

918 # points are not on a plane because Z varies 

919 if np.ptp(heights) > tol.planar: 

920 # since Z is inconsistent set height to zero 

921 height = 0.0 

922 if check: 

923 raise ValueError("points are not flat!") 

924 else: 

925 # if the points were planar store the height 

926 height = heights.mean() 

927 elif dim == 2: 

928 flat = self.vertices.copy() 

929 height = 0.0 

930 

931 # the transform from 2D to 3D 

932 to_3D = np.linalg.inv(to_2D) 

933 

934 # if the transform didn't move the path to 

935 # exactly Z=0 adjust it so the returned transform does 

936 if np.abs(height) > tol.planar: 

937 # adjust to_3D transform by height 

938 adjust = tf.translation_matrix([0, 0, height]) 

939 # apply the height adjustment to_3D 

940 to_3D = np.dot(to_3D, adjust) 

941 

942 # copy metadata to new object 

943 metadata = deepcopy(self.metadata) 

944 # store transform we used to move it onto the plane 

945 metadata["to_3D"] = to_3D 

946 

947 # create the Path2D with the same entities 

948 # and XY values of vertices projected onto the plane 

949 planar = Path2D( 

950 entities=deepcopy(self.entities), 

951 vertices=flat[:, :2], 

952 metadata=metadata, 

953 process=False, 

954 ) 

955 

956 return planar, to_3D 

957 

958 @cache_decorator 

959 def identifier(self) -> NDArray[float64]: 

960 """ 

961 Return a simple identifier for the 3D path. 

962 """ 

963 return np.concatenate( 

964 (comparison.identifier_simple(self.convex_hull), [self.length]) 

965 ) 

966 

967 @cache_decorator 

968 def convex_hull(self): 

969 """ 

970 Return a convex hull of the 3D path. 

971 

972 Returns 

973 -------- 

974 hull : trimesh.Trimesh 

975 A mesh of the convex hull of the 3D path. 

976 """ 

977 return convex.convex_hull(self.vertices[self.referenced_vertices]) 

978 

979 def show(self, **kwargs): 

980 """ 

981 Show the current Path3D object. 

982 """ 

983 scene = self.scene() 

984 return scene.show(**kwargs) 

985 

986 

987class Path2D(Path): 

988 """ 

989 Hold multiple vector curves (lines, arcs, splines, etc) in 3D. 

990 """ 

991 

992 def show(self, annotations=True): 

993 """ 

994 Plot the current Path2D object using matplotlib. 

995 """ 

996 if self.is_closed: 

997 self.plot_discrete(show=True, annotations=annotations) 

998 else: 

999 self.plot_entities(show=True, annotations=annotations) 

1000 

1001 def apply_obb(self): 

1002 """ 

1003 Transform the current path so that its OBB is axis aligned 

1004 and OBB center is at the origin. 

1005 

1006 Returns 

1007 ----------- 

1008 obb : (3, 3) float 

1009 Homogeneous transformation matrix 

1010 """ 

1011 matrix = self.obb 

1012 self.apply_transform(matrix) 

1013 return matrix 

1014 

1015 def apply_scale(self, scale): 

1016 """ 

1017 Apply a 2D scale to the current Path2D. 

1018 

1019 Parameters 

1020 ------------- 

1021 scale : float or (2,) float 

1022 Scale to apply in-place. 

1023 """ 

1024 matrix = np.eye(3) 

1025 matrix[:2, :2] *= scale 

1026 return self.apply_transform(matrix) 

1027 

1028 @cache_decorator 

1029 def obb(self): 

1030 """ 

1031 Get a transform that centers and aligns the OBB of the 

1032 referenced vertices with the XY axis. 

1033 

1034 Returns 

1035 ----------- 

1036 obb : (3, 3) float 

1037 Homogeneous transformation matrix 

1038 """ 

1039 matrix = bounds.oriented_bounds_2D(self.vertices[self.referenced_vertices])[0] 

1040 return matrix 

1041 

1042 @cache_decorator 

1043 def convex_hull(self) -> "Path2D": 

1044 """ 

1045 Return a convex hull of the 2D path. 

1046 

1047 Returns 

1048 -------- 

1049 hull 

1050 A convex hull of included vertices from this path. 

1051 """ 

1052 from scipy.spatial import ConvexHull 

1053 

1054 from .exchange.misc import edges_to_path 

1055 

1056 # include referenced vertices 

1057 candidates = [self.vertices[self.referenced_vertices]] 

1058 # include all points from discretized closed curves 

1059 # this prevents arcs from being collapsed past the 

1060 # discretization parameters set globally 

1061 candidates.extend(self.discrete) 

1062 candidates = np.vstack(candidates) 

1063 

1064 # if there's only 2 points this is a zero-area hull 

1065 if len(candidates) < 3: 

1066 return Path2D() 

1067 

1068 try: 

1069 # calculate a 2D convex hull for our candidate vertices 

1070 hull = ConvexHull(candidates) 

1071 except BaseException: 

1072 # this may raise if the geometry is colinear in 

1073 # which case an empty path is correct 

1074 log.debug("Failed to construct convex hull", exc_info=True) 

1075 return Path2D() 

1076 

1077 # map edges to throw away unused vertices 

1078 # as `hull.points` includes all input points 

1079 remap = np.arange(len(hull.points)) 

1080 remap[hull.vertices] = np.arange(len(hull.vertices)) 

1081 

1082 # get zero-indexed edges and only included vertices 

1083 edges = remap[hull.simplices] 

1084 vertices = hull.points[hull.vertices] 

1085 

1086 return Path2D(**edges_to_path(edges=edges, vertices=vertices)) 

1087 

1088 def rasterize( 

1089 self, pitch=None, origin=None, resolution=None, fill=True, width=None, **kwargs 

1090 ): 

1091 """ 

1092 Rasterize a Path2D object into a boolean image ("mode 1"). 

1093 

1094 Parameters 

1095 ------------ 

1096 pitch : float or (2,) float 

1097 Length(s) in model space of pixel edges 

1098 origin : (2,) float 

1099 Origin position in model space 

1100 resolution : (2,) int 

1101 Resolution in pixel space 

1102 fill : bool 

1103 If True will return closed regions as filled 

1104 width : int 

1105 If not None will draw outline this wide (pixels) 

1106 

1107 Returns 

1108 ------------ 

1109 raster : PIL.Image object, mode 1 

1110 Rasterized version of closed regions. 

1111 """ 

1112 image = raster.rasterize( 

1113 self, 

1114 pitch=pitch, 

1115 origin=origin, 

1116 resolution=resolution, 

1117 fill=fill, 

1118 width=width, 

1119 ) 

1120 return image 

1121 

1122 def sample(self, count, **kwargs): 

1123 """ 

1124 Use rejection sampling to generate random points inside a 

1125 polygon. 

1126 

1127 Parameters 

1128 ----------- 

1129 count : int 

1130 Number of points to return 

1131 If there are multiple bodies, there will 

1132 be up to count * bodies points returned 

1133 factor : float 

1134 How many points to test per loop 

1135 IE, count * factor 

1136 max_iter : int, 

1137 Maximum number of intersection loops 

1138 to run, total points sampled is 

1139 count * factor * max_iter 

1140 

1141 Returns 

1142 ----------- 

1143 hit : (n, 2) float 

1144 Random points inside polygon 

1145 """ 

1146 

1147 poly = self.polygons_full 

1148 if len(poly) == 0: 

1149 samples = np.array([]) 

1150 elif len(poly) == 1: 

1151 samples = polygons.sample(poly[0], count=count, **kwargs) 

1152 else: 

1153 samples = util.vstack_empty( 

1154 [polygons.sample(i, count=count, **kwargs) for i in poly] 

1155 ) 

1156 

1157 return samples 

1158 

1159 @property 

1160 def body_count(self): 

1161 """ 

1162 Returns a count of the number of unconnected polygons that 

1163 may contain other curves but aren't contained themselves. 

1164 

1165 Returns 

1166 --------- 

1167 body_count : int 

1168 Number of unconnected independent polygons. 

1169 """ 

1170 return len(self.root) 

1171 

1172 def to_3D(self, transform=None): 

1173 """ 

1174 Convert 2D path to 3D path on the XY plane. 

1175 

1176 Parameters 

1177 ------------- 

1178 transform : (4, 4) float 

1179 If passed, will transform vertices. 

1180 If not passed and 'to_3D' is in self.metadata 

1181 that transform will be used. 

1182 

1183 Returns 

1184 ----------- 

1185 path_3D : Path3D 

1186 3D version of current path 

1187 """ 

1188 # if there is a stored 'to_3D' transform in metadata use it 

1189 if transform is None and "to_3D" in self.metadata: 

1190 transform = self.metadata["to_3D"] 

1191 

1192 # copy vertices and stack with zeros from (n, 2) to (n, 3) 

1193 vertices = np.column_stack( 

1194 (deepcopy(self.vertices), np.zeros(len(self.vertices))) 

1195 ) 

1196 if transform is not None: 

1197 vertices = tf.transform_points(vertices, transform) 

1198 # make sure everything is deep copied 

1199 path_3D = Path3D( 

1200 entities=deepcopy(self.entities), 

1201 vertices=vertices, 

1202 metadata=deepcopy(self.metadata), 

1203 ) 

1204 return path_3D 

1205 

1206 @cache_decorator 

1207 def polygons_closed(self) -> NDArray: 

1208 """ 

1209 Cycles in the vertex graph, as shapely.geometry.Polygons. 

1210 These are polygon objects for every closed circuit, with no notion 

1211 of whether a polygon is a hole or an area. Every polygon in this 

1212 list will have an exterior, but NO interiors. 

1213 

1214 Returns 

1215 --------- 

1216 polygons_closed : (n,) list of shapely.geometry.Polygon objects 

1217 """ 

1218 # will attempt to recover invalid garbage geometry 

1219 # and will be None if geometry is unrecoverable 

1220 return polygons.paths_to_polygons(self.discrete) 

1221 

1222 @cache_decorator 

1223 def polygons_full(self) -> list: 

1224 """ 

1225 A list of shapely.geometry.Polygon objects with interiors created 

1226 by checking which closed polygons enclose which other polygons. 

1227 

1228 Returns 

1229 --------- 

1230 full : (len(self.root),) shapely.geometry.Polygon 

1231 Polygons containing interiors 

1232 """ 

1233 # pre- allocate the list to avoid indexing problems 

1234 full = [None] * len(self.root) 

1235 # store the graph to avoid cache thrashing 

1236 enclosure = self.enclosure_directed 

1237 # store closed polygons to avoid cache hits 

1238 closed = self.polygons_closed 

1239 

1240 # loop through root curves 

1241 for i, root in enumerate(self.root): 

1242 # a list of multiple Polygon objects that 

1243 # are fully contained by the root curve 

1244 children = [closed[child] for child in enclosure[root].keys()] 

1245 # all polygons_closed are CCW, so for interiors reverse them 

1246 holes = [np.array(p.exterior.coords)[::-1] for p in children] 

1247 # a single Polygon object 

1248 shell = closed[root].exterior 

1249 # create a polygon with interiors 

1250 full[i] = polygons.repair_invalid(Polygon(shell=shell, holes=holes)) 

1251 

1252 return full 

1253 

1254 @cache_decorator 

1255 def area(self): 

1256 """ 

1257 Return the area of the polygons interior. 

1258 

1259 Returns 

1260 --------- 

1261 area : float 

1262 Total area of polygons minus interiors 

1263 """ 

1264 area = float(sum(i.area for i in self.polygons_full)) 

1265 return area 

1266 

1267 def extrude(self, height, **kwargs): 

1268 """ 

1269 Extrude the current 2D path into a 3D mesh. 

1270 

1271 Parameters 

1272 ---------- 

1273 height: float, how far to extrude the profile 

1274 kwargs: passed directly to meshpy.triangle.build: 

1275 triangle.build(mesh_info, 

1276 verbose=False, 

1277 refinement_func=None, 

1278 attributes=False, 

1279 volume_constraints=True, 

1280 max_volume=None, 

1281 allow_boundary_steiner=True, 

1282 allow_volume_steiner=True, 

1283 quality_meshing=True, 

1284 generate_edges=None, 

1285 generate_faces=False, 

1286 min_angle=None) 

1287 Returns 

1288 -------- 

1289 mesh: trimesh object representing extruded polygon 

1290 """ 

1291 from ..primitives import Extrusion 

1292 

1293 result = [ 

1294 Extrusion(polygon=i, height=height, **kwargs) for i in self.polygons_full 

1295 ] 

1296 if len(result) == 1: 

1297 return result[0] 

1298 return result 

1299 

1300 def triangulate(self, **kwargs): 

1301 """ 

1302 Create a region- aware triangulation of the 2D path. 

1303 

1304 Parameters 

1305 ------------- 

1306 **kwargs : dict 

1307 Passed to `trimesh.creation.triangulate_polygon` 

1308 

1309 Returns 

1310 ------------- 

1311 vertices : (n, 2) float 

1312 2D vertices of triangulation 

1313 faces : (n, 3) int 

1314 Indexes of vertices for triangles 

1315 """ 

1316 from ..creation import triangulate_polygon 

1317 

1318 # append vertices and faces into sequence 

1319 v_seq = [] 

1320 f_seq = [] 

1321 

1322 # loop through polygons with interiors 

1323 for polygon in self.polygons_full: 

1324 v, f = triangulate_polygon(polygon, **kwargs) 

1325 v_seq.append(v) 

1326 f_seq.append(f) 

1327 

1328 return util.append_faces(v_seq, f_seq) 

1329 

1330 def medial_axis(self, resolution=None, clip=None): 

1331 """ 

1332 Find the approximate medial axis based 

1333 on a voronoi diagram of evenly spaced points on the 

1334 boundary of the polygon. 

1335 

1336 Parameters 

1337 ---------- 

1338 resolution : None or float 

1339 Distance between each sample on the polygon boundary 

1340 clip : None, or (2,) float 

1341 Min, max number of samples 

1342 

1343 Returns 

1344 ---------- 

1345 medial : Path2D object 

1346 Contains only medial axis of Path 

1347 """ 

1348 if resolution is None: 

1349 resolution = self.scale / 1000.0 

1350 

1351 # convert the edges to Path2D kwargs 

1352 from .exchange.misc import edges_to_path 

1353 

1354 # edges and vertices 

1355 edge_vert = [ 

1356 polygons.medial_axis(i, resolution, clip) for i in self.polygons_full 

1357 ] 

1358 # create a Path2D object for each region 

1359 medials = [Path2D(**edges_to_path(edges=e, vertices=v)) for e, v in edge_vert] 

1360 

1361 # get a single Path2D of medial axis 

1362 medial = concatenate(medials) 

1363 

1364 return medial 

1365 

1366 def connected_paths(self, path_id, include_self=False): 

1367 """ 

1368 Given an index of self.paths find other paths which 

1369 overlap with that path. 

1370 

1371 Parameters 

1372 ----------- 

1373 path_id : int 

1374 Index of self.paths 

1375 include_self : bool 

1376 Should the result include path_id or not 

1377 

1378 Returns 

1379 ----------- 

1380 path_ids : (n, ) int 

1381 Indexes of self.paths that overlap input path_id 

1382 """ 

1383 if len(self.root) == 1: 

1384 path_ids = np.arange(len(self.polygons_closed)) 

1385 else: 

1386 path_ids = list(nx.node_connected_component(self.enclosure, path_id)) 

1387 if include_self: 

1388 return np.array(path_ids) 

1389 return np.setdiff1d(path_ids, [path_id]) 

1390 

1391 def simplify(self, **kwargs): 

1392 """ 

1393 Return a version of the current path with colinear segments 

1394 merged, and circles entities replacing segmented circular paths. 

1395 

1396 Returns 

1397 --------- 

1398 simplified : Path2D object 

1399 """ 

1400 return simplify.simplify_basic(self, **kwargs) 

1401 

1402 def simplify_spline(self, smooth=0.0002, verbose=False): 

1403 """ 

1404 Convert paths into b-splines. 

1405 

1406 Parameters 

1407 ----------- 

1408 smooth : float 

1409 How much the spline should smooth the curve 

1410 verbose : bool 

1411 Print detailed log messages 

1412 

1413 Returns 

1414 ------------ 

1415 simplified : Path2D 

1416 Discrete curves replaced with splines 

1417 """ 

1418 return simplify.simplify_spline(self, smooth=smooth, verbose=verbose) 

1419 

1420 def split(self, **kwargs): 

1421 """ 

1422 If the current Path2D consists of n 'root' curves, 

1423 split them into a list of n Path2D objects 

1424 

1425 Returns 

1426 ---------- 

1427 split: (n,) list of Path2D objects 

1428 Each connected region and interiors 

1429 """ 

1430 return traversal.split(self) 

1431 

1432 def plot_discrete(self, show=False, annotations=True): 

1433 """ 

1434 Plot the closed curves of the path. 

1435 """ 

1436 import matplotlib.pyplot as plt # noqa 

1437 

1438 axis = plt.gca() 

1439 axis.set_aspect("equal", "datalim") 

1440 

1441 for i, points in enumerate(self.discrete): 

1442 color = ["g", "k"][i in self.root] 

1443 axis.plot(*points.T, color=color) 

1444 

1445 if annotations: 

1446 for e in self.entities: 

1447 if not hasattr(e, "plot"): 

1448 continue 

1449 e.plot(self.vertices) 

1450 

1451 if show: 

1452 plt.show() 

1453 return axis 

1454 

1455 def plot_entities(self, show=False, annotations=True, color=None): 

1456 """ 

1457 Plot the entities of the path with no notion of topology. 

1458 

1459 Parameters 

1460 ------------ 

1461 show : bool 

1462 Open a window immediately or not 

1463 annotations : bool 

1464 Call an entities custom plot function. 

1465 color : str 

1466 Override entity colors and make them all this color. 

1467 """ 

1468 import matplotlib.pyplot as plt # noqa 

1469 

1470 # keep plot axis scaled the same 

1471 axis = plt.gca() 

1472 axis.set_aspect("equal", "datalim") 

1473 # hardcode a format for each entity type 

1474 eformat = { 

1475 "Line0": {"color": "g", "linewidth": 1}, 

1476 "Line1": {"color": "y", "linewidth": 1}, 

1477 "Arc0": {"color": "r", "linewidth": 1}, 

1478 "Arc1": {"color": "b", "linewidth": 1}, 

1479 "Bezier0": {"color": "k", "linewidth": 1}, 

1480 "Bezier1": {"color": "k", "linewidth": 1}, 

1481 "BSpline0": {"color": "m", "linewidth": 1}, 

1482 "BSpline1": {"color": "m", "linewidth": 1}, 

1483 } 

1484 for entity in self.entities: 

1485 # if the entity has it's own plot method use it 

1486 if annotations and hasattr(entity, "plot"): 

1487 entity.plot(self.vertices) 

1488 continue 

1489 # otherwise plot the discrete curve 

1490 discrete = entity.discrete(self.vertices) 

1491 # a unique key for entities 

1492 e_key = entity.__class__.__name__ + str(int(entity.closed)) 

1493 

1494 fmt = eformat[e_key].copy() 

1495 if color is not None: 

1496 # passed color will override other options 

1497 fmt["color"] = color 

1498 elif hasattr(entity, "color"): 

1499 # if entity has specified color use it 

1500 fmt["color"] = entity.color 

1501 axis.plot(*discrete.T, **fmt) 

1502 if show: 

1503 plt.show() 

1504 

1505 @property 

1506 def identifier(self): 

1507 """ 

1508 A unique identifier for the path. 

1509 

1510 Returns 

1511 --------- 

1512 identifier : (5,) float 

1513 Unique identifier 

1514 """ 

1515 hasher = polygons.identifier 

1516 target = self.polygons_full 

1517 if len(target) == 1: 

1518 return hasher(self.polygons_full[0]) 

1519 elif len(target) == 0: 

1520 return np.zeros(5) 

1521 return np.sum([hasher(p) for p in target], axis=0) 

1522 

1523 @property 

1524 def path_valid(self): 

1525 """ 

1526 Returns 

1527 ---------- 

1528 path_valid : (n,) bool 

1529 Indexes of self.paths self.polygons_closed 

1530 which are valid polygons. 

1531 """ 

1532 return np.array([i is not None for i in self.polygons_closed], dtype=bool) 

1533 

1534 @cache_decorator 

1535 def root(self) -> NDArray[np.int64]: 

1536 """ 

1537 Which indexes of self.paths/self.polygons_closed 

1538 are root curves, also known as 'shell' or 'exterior. 

1539 

1540 Returns 

1541 --------- 

1542 root : (n,) int 

1543 List of indexes 

1544 """ 

1545 populate = self.enclosure_directed # NOQA 

1546 return self._cache["root"] 

1547 

1548 @cache_decorator 

1549 def enclosure(self): 

1550 """ 

1551 Undirected graph object of polygon enclosure. 

1552 

1553 Returns 

1554 ----------- 

1555 enclosure : networkx.Graph 

1556 Enclosure graph of self.polygons by index. 

1557 """ 

1558 with self._cache: 

1559 undirected = self.enclosure_directed.to_undirected() 

1560 return undirected 

1561 

1562 @cache_decorator 

1563 def enclosure_directed(self): 

1564 """ 

1565 Directed graph of polygon enclosure. 

1566 

1567 Returns 

1568 ---------- 

1569 enclosure_directed : networkx.DiGraph 

1570 Directed graph: child nodes are fully 

1571 contained by their parent node. 

1572 """ 

1573 root, enclosure = polygons.enclosure_tree(self.polygons_closed) 

1574 self._cache["root"] = root 

1575 return enclosure 

1576 

1577 @cache_decorator 

1578 def enclosure_shell(self): 

1579 """ 

1580 A dictionary of path indexes which are 'shell' paths, and values 

1581 of 'hole' paths. 

1582 

1583 Returns 

1584 ---------- 

1585 corresponding : dict 

1586 {index of self.paths of shell : [indexes of holes]} 

1587 """ 

1588 pairs = [(r, self.connected_paths(r, include_self=False)) for r in self.root] 

1589 # OrderedDict to maintain corresponding order 

1590 return collections.OrderedDict(pairs)