Coverage for trimesh/util.py: 87%

781 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-07-31 23:55 +0000

1""" 

2Grab bag of utility functions. 

3""" 

4 

5import abc 

6import base64 

7import collections 

8import json 

9import logging 

10import random 

11import shutil 

12import time 

13import uuid 

14import warnings 

15import zipfile 

16from collections.abc import ( 

17 Callable, 

18 Collection, 

19 Hashable, 

20 Iterator, 

21 Mapping, 

22 MutableMapping, 

23 MutableSet, 

24 Sequence, 

25) 

26from copy import deepcopy 

27from dataclasses import asdict as dataclass_to_dict 

28from io import BytesIO, StringIO 

29from typing import TYPE_CHECKING 

30 

31import numpy as np 

32 

33from .iteration import chain 

34 

35# use our wrapped types for wider version compatibility 

36from .typed import ( 

37 Any, 

38 ArrayLike, 

39 BoolIsFile, 

40 Floating, 

41 Integer, 

42 Iterable, 

43 NDArray, 

44 NDArray1D, 

45 NDArray2D, 

46 Number, 

47 Seed, 

48 Stream, 

49) 

50 

51# imported only so type checkers can resolve the dotted forward-ref 

52# string return below — never imported at runtime, and beartype 

53# re-imports the dotted path itself when the function is first called 

54if TYPE_CHECKING: 

55 import trimesh.parent 

56 

57# create a default logger 

58log: logging.Logger = logging.getLogger(__name__) 

59 

60ABC = abc.ABC 

61now = time.time 

62which = shutil.which 

63 

64# include constants here so we don't have to import 

65# a floating point threshold for 0.0 

66# we are setting it to 100x the resolution of a float64 

67# which works out to be 1e-13 

68TOL_ZERO: float = float(np.finfo(np.float64).resolution * 100) 

69# how close to merge vertices 

70TOL_MERGE: float = 1e-8 

71# enable additional potentially slow checks 

72_STRICT: bool = False 

73 

74# beartype is unable to resolve `NDArray[float64]` for globals 

75_IDENTITY: np.ndarray = np.eye(4, dtype=np.float64) 

76_IDENTITY.flags["WRITEABLE"] = False 

77 

78# one process-wide generator for the unseeded case: constructing one 

79# collects OS entropy which costs ~200x more than the draw it feeds 

80_RANDOM_DEFAULT = np.random.default_rng() 

81 

82 

83def random_generator(seed: Seed = None) -> np.random.Generator: 

84 """ 

85 Get a random generator, optionally seeded for deterministic results. 

86 

87 Parameters 

88 ---------- 

89 seed 

90 If None use a shared generator seeded from OS entropy. An integer 

91 seeds a fresh generator. A `Generator` is returned unaltered which 

92 lets a caller thread one stream through nested calls rather than 

93 re-seeding each of them to identical values. 

94 

95 Returns 

96 ------- 

97 generator 

98 Draw random values from this. 

99 """ 

100 if seed is None: 

101 # numpy locks the bit generator on every draw so sharing 

102 # this between threads is as safe as `numpy.random.random` 

103 return _RANDOM_DEFAULT 

104 return np.random.default_rng(seed) 

105 

106 

107def has_module(name: str) -> bool: 

108 """ 

109 Check to see if a module is installed by name without 

110 actually importing the module. 

111 

112 Parameters 

113 ------------ 

114 name : str 

115 The name of the module to check 

116 

117 Returns 

118 ------------ 

119 installed : bool 

120 True if module is installed 

121 """ 

122 from importlib.util import find_spec 

123 

124 return find_spec(name) is not None 

125 

126 

127def unitize( 

128 vectors: ArrayLike, 

129 check_valid: bool = False, 

130 threshold: float | None = None, 

131): 

132 """ 

133 Unitize a vector or an array or row-vectors. 

134 

135 Parameters 

136 ------------ 

137 vectors : (n,m) or (j) float 

138 Vector or vectors to be unitized 

139 check_valid : bool 

140 If set, will return mask of nonzero vectors 

141 threshold : float 

142 Cutoff for a value to be considered zero. 

143 

144 Returns 

145 --------- 

146 unit : (n,m) or (j) float 

147 Input vectors but unitized 

148 valid : (n,) bool or bool 

149 Mask of nonzero vectors returned if `check_valid` 

150 """ 

151 # make sure we have a numpy array 

152 vectors = np.asanyarray(vectors) 

153 

154 # allow user to set zero threshold 

155 if threshold is None: 

156 threshold = TOL_ZERO 

157 

158 if len(vectors.shape) == 2: 

159 # for (m, d) arrays take the per-row unit vector 

160 # using sqrt and avoiding exponents is slightly faster 

161 # also dot with ones is faser than .sum(axis=1) 

162 norm = np.sqrt(np.dot(vectors * vectors, [1.0] * vectors.shape[1])) 

163 # non-zero norms 

164 valid = norm > threshold 

165 # in-place reciprocal of nonzero norms 

166 norm[valid] **= -1 

167 # multiply by reciprocal of norm 

168 unit = vectors * norm.reshape((-1, 1)) 

169 

170 elif len(vectors.shape) == 1: 

171 # treat 1D arrays as a single vector 

172 norm = np.sqrt(np.dot(vectors, vectors)) 

173 valid = norm > threshold 

174 if valid: 

175 unit = vectors / norm 

176 else: 

177 unit = vectors.copy() 

178 else: 

179 raise ValueError("vectors must be (n, ) or (n, d)!") 

180 

181 if check_valid: 

182 return unit[valid], valid 

183 return unit 

184 

185 

186def euclidean(a: ArrayLike, b: ArrayLike) -> np.float64: 

187 """ 

188 DEPRECATED: use `np.linalg.norm(a - b)` instead of this. 

189 """ 

190 warnings.warn( 

191 "`trimesh.util.euclidean` is deprecated " 

192 + "and will be removed in January 2025. " 

193 + "replace with `np.linalg.norm(a - b)`", 

194 category=DeprecationWarning, 

195 stacklevel=2, 

196 ) 

197 

198 a = np.asanyarray(a, dtype=np.float64) 

199 b = np.asanyarray(b, dtype=np.float64) 

200 return np.sqrt(((a - b) ** 2).sum()) 

201 

202 

203def is_file(obj: Any) -> BoolIsFile: 

204 """ 

205 Check if an object is file-like 

206 

207 Parameters 

208 ------------ 

209 obj : object 

210 Any object type to be checked 

211 

212 Returns 

213 ----------- 

214 is_file : bool 

215 True if object is a file 

216 """ 

217 return hasattr(obj, "read") or hasattr(obj, "write") 

218 

219 

220def is_pathlib(obj: object) -> bool: 

221 """ 

222 Check if the object is a `pathlib.Path` or subclass. 

223 

224 Parameters 

225 ------------ 

226 obj : object 

227 Object to be checked 

228 

229 Returns 

230 ------------ 

231 is_pathlib : bool 

232 Is the input object a pathlib path 

233 """ 

234 # check class name rather than a pathlib import 

235 name = obj.__class__.__name__ 

236 return hasattr(obj, "absolute") and name.endswith("Path") 

237 

238 

239def is_string(obj: object) -> bool: 

240 """ 

241 DEPRECATED : this is not necessary since we dropped Python 2. 

242 

243 Replace with `isinstance(obj, str)` 

244 """ 

245 warnings.warn( 

246 "`trimesh.util.is_string` is deprecated " 

247 + "and will be removed in January 2025. " 

248 + "replace with `isinstance(obj, str)`", 

249 category=DeprecationWarning, 

250 stacklevel=2, 

251 ) 

252 

253 return isinstance(obj, str) 

254 

255 

256def is_sequence(obj: Any) -> bool: 

257 """ 

258 Check if an object is a sequence or not. 

259 

260 Parameters 

261 ------------- 

262 obj : object 

263 Any object type to be checked 

264 

265 Returns 

266 ------------- 

267 is_sequence : bool 

268 True if object is sequence 

269 """ 

270 seq = (not hasattr(obj, "strip") and hasattr(obj, "__getitem__")) or hasattr( 

271 obj, "__iter__" 

272 ) 

273 

274 # check to make sure it is not a set, string, or dictionary 

275 seq = seq and all(not isinstance(obj, i) for i in (dict, set, str)) 

276 

277 # PointCloud objects can look like an array but are not 

278 seq = seq and type(obj).__name__ not in ["PointCloud"] 

279 

280 # numpy sometimes returns objects that are single float64 values 

281 # but sure look like sequences, so we check the shape 

282 if hasattr(obj, "shape"): 

283 seq = seq and obj.shape != () 

284 

285 return seq 

286 

287 

288def is_shape( 

289 obj: NDArray | Any, 

290 shape: Sequence[int | tuple[int, ...]], 

291 allow_zeros: bool = False, 

292) -> bool: 

293 """ 

294 Compare the shape of a numpy.ndarray to a target shape, 

295 with any value less than zero being considered a wildcard 

296 

297 Note that if a list-like object is passed that is not a numpy 

298 array, this function will not convert it and will return False. 

299 

300 Parameters 

301 ------------ 

302 obj : np.ndarray 

303 Array to check the shape on 

304 shape : list or tuple 

305 Any negative term will be considered a wildcard 

306 Any tuple term will be evaluated as an OR 

307 allow_zeros: bool 

308 if False, zeros do not match negatives in shape 

309 

310 Returns 

311 --------- 

312 shape_ok : bool 

313 True if shape of obj matches query shape 

314 

315 Examples 

316 ------------------------ 

317 In [1]: a = np.random.random((100, 3)) 

318 

319 In [2]: a.shape 

320 Out[2]: (100, 3) 

321 

322 In [3]: trimesh.util.is_shape(a, (-1, 3)) 

323 Out[3]: True 

324 

325 In [4]: trimesh.util.is_shape(a, (-1, 3, 5)) 

326 Out[4]: False 

327 

328 In [5]: trimesh.util.is_shape(a, (100, -1)) 

329 Out[5]: True 

330 

331 In [6]: trimesh.util.is_shape(a, (-1, (3, 4))) 

332 Out[6]: True 

333 

334 In [7]: trimesh.util.is_shape(a, (-1, (4, 5))) 

335 Out[7]: False 

336 """ 

337 

338 # if the obj.shape is different length than 

339 # the goal shape it means they have different number 

340 # of dimensions and thus the obj is not the query shape 

341 if not hasattr(obj, "shape") or len(obj.shape) != len(shape): 

342 return False 

343 

344 # empty lists with any flexible dimensions match 

345 if len(obj) == 0 and -1 in shape: 

346 return True 

347 

348 # loop through each integer of the two shapes 

349 # multiple values are sequences 

350 # wildcards are less than zero (i.e. -1) 

351 for i, target in zip(obj.shape, shape): 

352 # check if current field has multiple acceptable values 

353 # an explicit tuple/list check narrows `target` for type 

354 # checkers — `is_sequence` is not a type guard 

355 if isinstance(target, (list, tuple)): 

356 if i in target: 

357 # obj shape is in the accepted values 

358 continue 

359 else: 

360 return False 

361 

362 # check if current field is a wildcard 

363 if int(target) < 0: 

364 if i == 0 and not allow_zeros: 

365 # if a dimension is 0, we don't allow 

366 # that to match to a wildcard 

367 # it would have to be explicitly called out as 0 

368 return False 

369 else: 

370 continue 

371 # since we have a single target and a single value, 

372 # if they are not equal we have an answer 

373 if target != i: 

374 return False 

375 

376 # since none of the checks failed the obj.shape 

377 # matches the pattern 

378 return True 

379 

380 

381def make_sequence(obj: Any) -> list: 

382 """ 

383 Given an object, if it is a sequence return, otherwise 

384 add it to a length 1 sequence and return. 

385 

386 Useful for wrapping functions which sometimes return single 

387 objects and other times return lists of objects. 

388 

389 Parameters 

390 ------------- 

391 obj : object 

392 An object to be made a sequence 

393 

394 Returns 

395 -------------- 

396 as_sequence : (n,) sequence 

397 Contains input value 

398 """ 

399 if is_sequence(obj): 

400 return list(obj) 

401 else: 

402 return [obj] 

403 

404 

405def vector_hemisphere( 

406 vectors: ArrayLike, 

407 return_sign: bool = False, 

408): 

409 """ 

410 For a set of 3D vectors alter the sign so they are all in the 

411 upper hemisphere. 

412 

413 If the vector lies on the plane all vectors with negative Y 

414 will be reversed. 

415 

416 If the vector has a zero Z and Y value vectors with a 

417 negative X value will be reversed. 

418 

419 Parameters 

420 ------------ 

421 vectors : (n, 3) float 

422 Input vectors 

423 return_sign : bool 

424 Return the sign mask or not 

425 

426 Returns 

427 ---------- 

428 oriented: (n, 3) float 

429 Vectors with same magnitude as source 

430 but possibly reversed to ensure all vectors 

431 are in the same hemisphere. 

432 sign : (n,) float 

433 [OPTIONAL] sign of original vectors 

434 """ 

435 # vectors as numpy array 

436 vectors = np.asanyarray(vectors, dtype=np.float64) 

437 

438 if is_shape(vectors, (-1, 2)): 

439 # 2D vector case 

440 # check the Y value and reverse vector 

441 # direction if negative. 

442 negative = vectors < -TOL_ZERO 

443 zero = np.logical_not(np.logical_or(negative, vectors > TOL_ZERO)) 

444 

445 signs = np.ones(len(vectors), dtype=np.float64) 

446 # negative Y values are reversed 

447 signs[negative[:, 1]] = -1.0 

448 

449 # zero Y and negative X are reversed 

450 signs[np.logical_and(zero[:, 1], negative[:, 0])] = -1.0 

451 

452 elif is_shape(vectors, (-1, 3)): 

453 # 3D vector case 

454 negative = vectors < -TOL_ZERO 

455 zero = np.logical_not(np.logical_or(negative, vectors > TOL_ZERO)) 

456 # move all negative Z to positive 

457 # then for zero Z vectors, move all negative Y to positive 

458 # then for zero Y vectors, move all negative X to positive 

459 signs = np.ones(len(vectors), dtype=np.float64) 

460 # all vectors with negative Z values 

461 signs[negative[:, 2]] = -1.0 

462 # all on-plane vectors with negative Y values 

463 signs[np.logical_and(zero[:, 2], negative[:, 1])] = -1.0 

464 # all on-plane vectors with zero Y values 

465 # and negative X values 

466 signs[ 

467 np.logical_and(np.logical_and(zero[:, 2], zero[:, 1]), negative[:, 0]) 

468 ] = -1.0 

469 

470 else: 

471 raise ValueError("vectors must be (n, 3)!") 

472 

473 # apply the signs to the vectors 

474 oriented = vectors * signs.reshape((-1, 1)) 

475 

476 if return_sign: 

477 return oriented, signs 

478 

479 return oriented 

480 

481 

482def vector_to_spherical(cartesian: ArrayLike) -> NDArray2D[np.float64]: 

483 """ 

484 Convert a set of cartesian points to (n, 2) spherical unit 

485 vectors. 

486 

487 Parameters 

488 ------------ 

489 cartesian : (n, 3) float 

490 Points in space 

491 

492 Returns 

493 ------------ 

494 spherical : (n, 2) float 

495 Angles, in radians 

496 """ 

497 cartesian = np.asanyarray(cartesian, dtype=np.float64) 

498 if not is_shape(cartesian, (-1, 3)): 

499 raise ValueError("Cartesian points must be (n, 3)!") 

500 

501 unit, valid = unitize(cartesian, check_valid=True) 

502 unit[np.abs(unit) < TOL_MERGE] = 0.0 

503 

504 x, y, z = unit.T 

505 spherical = np.zeros((len(cartesian), 2), dtype=np.float64) 

506 spherical[valid] = np.column_stack((np.arctan2(y, x), np.arccos(z))) 

507 return spherical 

508 

509 

510def spherical_to_vector(spherical: ArrayLike) -> NDArray2D[np.float64]: 

511 """ 

512 Convert an array of `(n, 2)` spherical angles to `(n, 3)` unit vectors. 

513 

514 Parameters 

515 ------------ 

516 spherical : (n , 2) float 

517 Angles, in radians 

518 

519 Returns 

520 ----------- 

521 vectors : (n, 3) float 

522 Unit vectors 

523 """ 

524 spherical = np.asanyarray(spherical, dtype=np.float64) 

525 if not is_shape(spherical, (-1, 2)): 

526 raise ValueError("spherical coordinates must be (n, 2)!") 

527 

528 theta, phi = spherical.T 

529 st, ct = np.sin(theta), np.cos(theta) 

530 sp, cp = np.sin(phi), np.cos(phi) 

531 return np.column_stack((ct * sp, st * sp, cp)) 

532 

533 

534def pairwise(iterable: Iterable[Any]): 

535 """ 

536 For an iterable, group values into pairs. 

537 

538 Parameters 

539 ------------ 

540 iterable : (m, ) list 

541 A sequence of values 

542 

543 Returns 

544 ----------- 

545 pairs: (n, 2) 

546 Pairs of sequential values 

547 

548 Example 

549 ----------- 

550 In [1]: data 

551 Out[1]: [0, 1, 2, 3, 4, 5, 6] 

552 

553 In [2]: list(trimesh.util.pairwise(data)) 

554 Out[2]: [(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6)] 

555 

556 """ 

557 # looping through a giant numpy array would be dumb 

558 # so special case ndarrays and use numpy operations 

559 if isinstance(iterable, np.ndarray): 

560 iterable = iterable.reshape(-1) 

561 stacked = np.column_stack((iterable, iterable)) 

562 pairs = stacked.reshape(-1)[1:-1].reshape((-1, 2)) 

563 return pairs 

564 

565 # if we have a normal iterable use itertools 

566 import itertools 

567 

568 a, b = itertools.tee(iterable) 

569 # pop the first element of the second item 

570 next(b) 

571 

572 return zip(a, b) 

573 

574 

575multi_dot = np.linalg.multi_dot 

576 

577 

578def diagonal_dot(a: ArrayLike, b: ArrayLike) -> np.ndarray[tuple[int], np.dtype[Any]]: 

579 """ 

580 Dot product by row of a and b. 

581 

582 There are a lot of ways to do this though 

583 performance varies very widely. This method 

584 uses a dot product to sum the row and avoids 

585 function calls if at all possible. 

586 

587 Comparing performance of some equivalent versions: 

588 ``` 

589 In [1]: import numpy as np; import trimesh 

590 

591 In [2]: a = np.random.random((10000, 3)) 

592 

593 In [3]: b = np.random.random((10000, 3)) 

594 

595 In [4]: %timeit (a * b).sum(axis=1) 

596 1000 loops, best of 3: 181 us per loop 

597 

598 In [5]: %timeit np.einsum('ij,ij->i', a, b) 

599 10000 loops, best of 3: 62.7 us per loop 

600 

601 In [6]: %timeit np.diag(np.dot(a, b.T)) 

602 1 loop, best of 3: 429 ms per loop 

603 

604 In [7]: %timeit np.dot(a * b, np.ones(a.shape[1])) 

605 10000 loops, best of 3: 61.3 us per loop 

606 

607 In [8]: %timeit trimesh.util.diagonal_dot(a, b) 

608 10000 loops, best of 3: 55.2 us per loop 

609 ``` 

610 

611 Parameters 

612 ------------ 

613 a : (m, d) float 

614 First array 

615 b : (m, d) float 

616 Second array 

617 

618 Returns 

619 ------------- 

620 result : (m,) float 

621 Dot product of each row 

622 """ 

623 # make sure `a` is numpy array 

624 # doing it for `a` will force the multiplication to 

625 # convert `b` if necessary and avoid function call otherwise 

626 a = np.asanyarray(a) 

627 # 3x faster than (a * b).sum(axis=1) 

628 # avoiding np.ones saves 5-10% sometimes 

629 return np.dot(a * b, [1.0] * a.shape[1]) 

630 

631 

632def row_norm(data: NDArray2D[Any]) -> NDArray1D[np.float64]: 

633 """ 

634 Compute the norm per-row of a numpy array. 

635 

636 This is identical to np.linalg.norm(data, axis=1) but roughly 

637 three times faster due to being less general. 

638 

639 In [3]: %timeit trimesh.util.row_norm(a) 

640 76.3 us +/- 651 ns per loop 

641 

642 In [4]: %timeit np.linalg.norm(a, axis=1) 

643 220 us +/- 5.41 us per loop 

644 

645 Parameters 

646 ------------- 

647 data : (n, d) float 

648 Input 2D data to calculate per-row norm of 

649 

650 Returns 

651 ------------- 

652 norm : (n,) float 

653 Norm of each row of input array 

654 """ 

655 return np.sqrt(np.dot(data**2, [1] * data.shape[1])) 

656 

657 

658def stack_3D( 

659 points: ArrayLike, 

660 return_2D: bool = False, 

661) -> NDArray2D[np.float64] | tuple[NDArray2D[np.float64], bool]: 

662 """ 

663 For a list of (n, 2) or (n, 3) points return them 

664 as (n, 3) 3D points, 2D points on the XY plane. 

665 

666 Parameters 

667 ------------ 

668 points : (n, 2) or (n, 3) float 

669 Points in either 2D or 3D space 

670 return_2D : bool 

671 Were the original points 2D? 

672 

673 Returns 

674 ---------- 

675 points : (n, 3) float 

676 Points in space 

677 is_2D : bool 

678 [OPTIONAL] if source points were (n, 2) 

679 """ 

680 points = np.asanyarray(points, dtype=np.float64) 

681 shape = points.shape 

682 

683 if shape == (0,): 

684 is_2D = False 

685 elif len(shape) != 2: 

686 raise ValueError("Points must be 2D array!") 

687 elif shape[1] == 2: 

688 points = np.column_stack((points, np.zeros(len(points)))) 

689 is_2D = True 

690 elif shape[1] == 3: 

691 is_2D = False 

692 else: 

693 raise ValueError("Points must be (n, 2) or (n, 3)!") 

694 

695 if return_2D: 

696 return points, is_2D 

697 

698 return points 

699 

700 

701def grid_arange(bounds: ArrayLike, step: Number | ArrayLike) -> NDArray2D[np.float64]: 

702 """ 

703 Return a grid from an (2,dimension) bounds with samples step distance apart. 

704 

705 Parameters 

706 ------------ 

707 bounds: (2,dimension) list of [[min x, min y, etc], [max x, max y, etc]] 

708 step: float, or (dimension) floats, separation between points 

709 

710 Returns 

711 --------- 

712 grid: (n, dimension), points inside the specified bounds 

713 """ 

714 bounds = np.asanyarray(bounds, dtype=np.float64) 

715 if len(bounds) != 2: 

716 raise ValueError("bounds must be (2, dimension!") 

717 

718 # allow single float or per-dimension spacing 

719 step = np.asanyarray(step, dtype=np.float64) 

720 if step.shape == (): 

721 step = np.tile(step, bounds.shape[1]) 

722 

723 grid_elements = [np.arange(*b, step=s) for b, s in zip(bounds.T, step)] 

724 grid = ( 

725 np.vstack(np.meshgrid(*grid_elements, indexing="ij")) 

726 .reshape(bounds.shape[1], -1) 

727 .T 

728 ) 

729 return grid 

730 

731 

732def grid_linspace(bounds: ArrayLike, count: Integer | ArrayLike) -> NDArray2D[np.float64]: 

733 """ 

734 Return a grid spaced inside a bounding box with edges spaced using np.linspace. 

735 

736 Parameters 

737 ------------ 

738 bounds: (2,dimension) list of [[min x, min y, etc], [max x, max y, etc]] 

739 count: int, or (dimension,) int, number of samples per side 

740 

741 Returns 

742 --------- 

743 grid: (n, dimension) float, points in the specified bounds 

744 """ 

745 bounds = np.asanyarray(bounds, dtype=np.float64) 

746 if len(bounds) != 2: 

747 raise ValueError("bounds must be (2, dimension!") 

748 

749 count = np.asanyarray(count, dtype=np.int64) 

750 if count.shape == (): 

751 count = np.tile(count, bounds.shape[1]) 

752 

753 grid_elements = [np.linspace(*b, num=c) for b, c in zip(bounds.T, count)] 

754 grid = ( 

755 np.vstack(np.meshgrid(*grid_elements, indexing="ij")) 

756 .reshape(bounds.shape[1], -1) 

757 .T 

758 ) 

759 return grid 

760 

761 

762def multi_dict( 

763 pairs: ArrayLike | Iterable[tuple[Hashable, Any]], 

764) -> collections.defaultdict[Hashable, list]: 

765 """ 

766 Given a set of key value pairs, create a dictionary. 

767 If a key occurs multiple times, stack the values into an array. 

768 

769 Can be called like the regular dict(pairs) constructor 

770 

771 Parameters 

772 ------------ 

773 pairs: (n, 2) array of key, value pairs 

774 

775 Returns 

776 ---------- 

777 result: dict, with all values stored (rather than last with regular dict) 

778 

779 """ 

780 result = collections.defaultdict(list) 

781 for k, v in pairs: 

782 result[k].append(v) 

783 return result 

784 

785 

786def tolist(data: object) -> Any: 

787 """ 

788 Ensure that any arrays or dicts passed containing 

789 numpy arrays are properly converted to lists 

790 

791 Parameters 

792 ------------- 

793 data : any 

794 Usually a dict with some numpy arrays as values 

795 

796 Returns 

797 ---------- 

798 result : any 

799 JSON-serializable version of data 

800 """ 

801 result = json.loads(jsonify(data)) 

802 return result 

803 

804 

805def is_binary_file(file_obj: Stream) -> bool: 

806 """ 

807 Returns True if file has non-ASCII characters (> 0x7F, or 127) 

808 """ 

809 start = file_obj.tell() 

810 fbytes = file_obj.read(1024) 

811 file_obj.seek(start) 

812 is_str = isinstance(fbytes, str) 

813 for fbyte in fbytes: 

814 if is_str: 

815 code = ord(fbyte) 

816 else: 

817 code = fbyte 

818 if code > 127: 

819 return True 

820 return False 

821 

822 

823def distance_to_end(file_obj: Stream) -> int: 

824 """ 

825 For an open file object how far is it to the end 

826 

827 Parameters 

828 ------------ 

829 file_obj: open file-like object 

830 

831 Returns 

832 ---------- 

833 distance: int, bytes to end of file 

834 """ 

835 position_current = file_obj.tell() 

836 file_obj.seek(0, 2) 

837 position_end = file_obj.tell() 

838 file_obj.seek(position_current) 

839 distance = position_end - position_current 

840 return distance 

841 

842 

843def decimal_to_digits(decimal: Floating, min_digits: Integer | None = None) -> int: 

844 """ 

845 Return the number of digits to the first nonzero decimal. 

846 

847 Parameters 

848 ----------- 

849 decimal: float 

850 min_digits: int, minimum number of digits to return 

851 

852 Returns 

853 ----------- 

854 

855 digits: int, number of digits to the first nonzero decimal 

856 """ 

857 digits = abs(int(np.log10(decimal))) 

858 if min_digits is not None: 

859 digits = np.clip(digits, min_digits, 20) 

860 return int(digits) 

861 

862 

863def attach_to_log( 

864 level: Integer = logging.DEBUG, 

865 handler: logging.Handler | None = None, 

866 loggers: MutableSet[logging.Logger] | None = None, 

867 colors: bool = True, 

868 capture_warnings: bool = True, 

869 blacklist: Iterable[str] | None = None, 

870 only_parent: bool = True, 

871) -> None: 

872 """ 

873 Attach a stream handler to all loggers. 

874 

875 Parameters 

876 ------------ 

877 level : enum 

878 Logging level, like logging.INFO 

879 handler : None or logging.Handler 

880 Handler to attach 

881 loggers : None or (n,) logging.Logger 

882 If None, will try to attach to all available 

883 colors : bool 

884 If True try to use colorlog formatter 

885 blacklist : (n,) str 

886 Names of loggers NOT to attach to 

887 only_parent 

888 Only attach to parent loggers, i.e. `trimesh`, `trimesh.sub1`, `trimesh.sub2` 

889 will only attach to `trimesh` and not the sub-loggers 

890 """ 

891 

892 # default blacklist includes ipython debugging stuff 

893 if blacklist is None: 

894 blacklist = [ 

895 "TerminalIPythonApp", 

896 "PYREADLINE", 

897 "pyembree", 

898 "shapely", 

899 "matplotlib", 

900 "parso.cache", 

901 "parso", 

902 "parso.python.diff", 

903 "asyncio", 

904 "prompt_toolkit.buffer", 

905 ] 

906 

907 # make sure we log warnings from the warnings module 

908 logging.captureWarnings(capture_warnings) 

909 

910 # create a basic formatter 

911 formatter = logging.Formatter( 

912 "[%(asctime)s] %(levelname)-7s (%(filename)s:%(lineno)3s) %(message)s", 

913 "%Y-%m-%d %H:%M:%S", 

914 ) 

915 if colors: 

916 try: 

917 from colorlog import ColoredFormatter 

918 

919 formatter = ColoredFormatter( 

920 ( 

921 "%(log_color)s%(levelname)-8s%(reset)s " 

922 + "%(filename)17s:%(lineno)-4s %(blue)4s%(message)s" 

923 ), 

924 datefmt=None, 

925 reset=True, 

926 log_colors={ 

927 "DEBUG": "cyan", 

928 "INFO": "green", 

929 "WARNING": "yellow", 

930 "ERROR": "red", 

931 "CRITICAL": "red", 

932 }, 

933 ) 

934 except ImportError: 

935 pass 

936 

937 # if no handler was passed use a StreamHandler 

938 if handler is None: 

939 handler = logging.StreamHandler() 

940 

941 # add the formatters and set the level 

942 handler.setFormatter(formatter) 

943 # numpy integers aren't `int` subclasses — coerce for the stdlib stubs 

944 handler.setLevel(int(level)) 

945 

946 # if nothing passed use all available loggers 

947 if loggers is None: 

948 # de-duplicate loggers using a set 

949 loggers = set(logging.Logger.manager.loggerDict.values()) 

950 

951 # add the warnings logging 

952 loggers.add(logging.getLogger("py.warnings")) 

953 

954 # disable pyembree warnings 

955 logging.getLogger("pyembree").disabled = True 

956 

957 # cull loggers that are not actually loggers or are on the blacklist 

958 loggers_dict = { 

959 L.name: L 

960 for L in loggers 

961 if hasattr(L, "name") 

962 and isinstance(L, logging.Logger) 

963 and L.name not in blacklist 

964 } 

965 

966 if only_parent: 

967 # create a new dict to store only parent loggers 

968 parent_loggers = {} 

969 # sort logger names to process in hierarchical order 

970 for name in sorted(loggers_dict.keys()): 

971 # if it's not a child of any existing parent, add it as a parent 

972 if not any(name.startswith(f"{p}.") for p in parent_loggers.keys()): 

973 parent_loggers[name] = loggers_dict[name] 

974 # replace loggers dict with only parent loggers 

975 loggers_dict = parent_loggers 

976 

977 # loop through all available loggers 

978 for logger in loggers_dict.values(): 

979 logger.addHandler(handler) 

980 logger.setLevel(int(level)) 

981 

982 # set nicer numpy print options 

983 np.set_printoptions(precision=5, suppress=True) 

984 

985 

986def stack_lines(indices: ArrayLike) -> NDArray: 

987 """ 

988 Stack a list of values that represent a polyline into 

989 individual line segments with duplicated consecutive values. 

990 

991 Parameters 

992 ------------ 

993 indices : (m,) any 

994 List of items to be stacked 

995 

996 Returns 

997 --------- 

998 stacked : (n, 2) any 

999 Stacked items 

1000 

1001 Examples 

1002 ---------- 

1003 In [1]: trimesh.util.stack_lines([0, 1, 2]) 

1004 Out[1]: 

1005 array([[0, 1], 

1006 [1, 2]]) 

1007 

1008 In [2]: trimesh.util.stack_lines([0, 1, 2, 4, 5]) 

1009 Out[2]: 

1010 array([[0, 1], 

1011 [1, 2], 

1012 [2, 4], 

1013 [4, 5]]) 

1014 

1015 In [3]: trimesh.util.stack_lines([[0, 0], [1, 1], [2, 2], [3, 3]]) 

1016 Out[3]: 

1017 array([[0, 0], 

1018 [1, 1], 

1019 [1, 1], 

1020 [2, 2], 

1021 [2, 2], 

1022 [3, 3]]) 

1023 

1024 """ 

1025 indices = np.asanyarray(indices) 

1026 if len(indices) == 0: 

1027 return np.array([]) 

1028 elif is_sequence(indices[0]): 

1029 shape = (-1, len(indices[0])) 

1030 else: 

1031 shape = (-1, 2) 

1032 return np.column_stack((indices[:-1], indices[1:])).reshape(shape) 

1033 

1034 

1035def append_faces( 

1036 vertices_seq: Iterable[ArrayLike], 

1037 faces_seq: Iterable[ArrayLike], 

1038) -> tuple[NDArray2D[np.floating], NDArray2D[np.integer]]: 

1039 """ 

1040 Given a sequence of zero-indexed faces and vertices 

1041 combine them into a single array of faces and 

1042 a single array of vertices. 

1043 

1044 Parameters 

1045 ----------- 

1046 vertices_seq : (n, ) sequence of (m, d) float 

1047 Multiple arrays of verticesvertex arrays 

1048 faces_seq : (n, ) sequence of (p, j) int 

1049 Zero indexed faces for matching vertices 

1050 

1051 Returns 

1052 ---------- 

1053 vertices : (i, d) float 

1054 Points in space 

1055 faces : (j, 3) int 

1056 Reference vertex indices 

1057 """ 

1058 # the length of each vertex array 

1059 vertices_len = np.array([len(i) for i in vertices_seq], dtype=np.int64) 

1060 # how much each group of faces needs to be offset 

1061 face_offset = np.append(0, np.cumsum(vertices_len)[:-1]) 

1062 

1063 new_faces = [] 

1064 for offset, faces in zip(face_offset, faces_seq): 

1065 if len(faces) == 0: 

1066 continue 

1067 # apply the index offset 

1068 new_faces.append(faces + offset) 

1069 # stack to clean (n, 3) float 

1070 vertices = vstack_empty(vertices_seq) 

1071 # stack to clean (n, 3) int 

1072 faces = vstack_empty(new_faces) 

1073 

1074 # an all-empty stack collapses to a 1d float array — restore the 

1075 # documented 2d shape and integer face dtype 

1076 if len(vertices) == 0: 

1077 vertices = vertices.reshape((0, 3)) 

1078 if len(faces) == 0: 

1079 faces = faces.reshape((0, 3)).astype(np.int64) 

1080 

1081 return vertices, faces 

1082 

1083 

1084def array_to_string( 

1085 array: ArrayLike, 

1086 col_delim: str = " ", 

1087 row_delim: str = "\n", 

1088 digits: Integer = 8, 

1089 value_format: str = "{}", 

1090) -> str: 

1091 """ 

1092 Convert a 1 or 2D array into a string with a specified number 

1093 of digits and delimiter. The reason this exists is that the 

1094 basic numpy array to string conversions are surprisingly slow. 

1095 

1096 Parameters 

1097 ------------ 

1098 array : (n,) or (n, d) float or int 

1099 Data to be converted 

1100 If shape is (n,) only column delimiter will be used 

1101 col_delim : str 

1102 What string should separate values in a column 

1103 row_delim : str 

1104 What string should separate values in a row 

1105 digits : int 

1106 How many digits should floating point numbers include 

1107 value_format : str 

1108 Format string for each value or sequence of values 

1109 If multiple values per value_format it must divide 

1110 into array evenly. 

1111 

1112 Returns 

1113 ---------- 

1114 formatted : str 

1115 String representation of original array 

1116 """ 

1117 # convert inputs to correct types 

1118 array = np.asanyarray(array) 

1119 digits = int(digits) 

1120 row_delim = str(row_delim) 

1121 col_delim = str(col_delim) 

1122 value_format = str(value_format) 

1123 

1124 # abort for non-flat arrays 

1125 if len(array.shape) > 2: 

1126 raise ValueError( 

1127 "conversion only works on 1D/2D arrays not %s!", str(array.shape) 

1128 ) 

1129 

1130 # abort for structured arrays 

1131 if array.dtype.names is not None: 

1132 raise ValueError("array is structured, use structured_array_to_string instead") 

1133 

1134 # allow a value to be repeated in a value format 

1135 repeats = value_format.count("{") 

1136 

1137 if array.dtype.kind in ["i", "u"]: 

1138 # integer types don't need a specified precision 

1139 format_str = value_format + col_delim 

1140 elif array.dtype.kind == "f": 

1141 # add the digits formatting to floats 

1142 format_str = value_format.replace("{}", "{:." + str(digits) + "f}") + col_delim 

1143 else: 

1144 raise ValueError("dtype %s not convertible!", array.dtype.name) 

1145 

1146 # length of extra delimiters at the end 

1147 end_junk = len(col_delim) 

1148 # if we have a 2D array add a row delimiter 

1149 if len(array.shape) == 2: 

1150 format_str *= array.shape[1] 

1151 # cut off the last column delimiter and add a row delimiter 

1152 format_str = format_str[: -len(col_delim)] + row_delim 

1153 end_junk = len(row_delim) 

1154 

1155 # expand format string to whole array 

1156 format_str *= len(array) 

1157 

1158 # if an array is repeated in the value format 

1159 # do the shaping here so we don't need to specify indexes 

1160 shaped = np.tile(array.reshape((-1, 1)), (1, repeats)).reshape(-1) 

1161 

1162 # run the format operation and remove the extra delimiters 

1163 formatted = format_str.format(*shaped)[:-end_junk] 

1164 

1165 return formatted 

1166 

1167 

1168def structured_array_to_string( 

1169 array: ArrayLike, 

1170 col_delim: str = " ", 

1171 row_delim: str = "\n", 

1172 digits: Integer = 8, 

1173 value_format: str = "{}", 

1174) -> str: 

1175 """ 

1176 Convert an unstructured array into a string with a specified 

1177 number of digits and delimiter. The reason thisexists is 

1178 that the basic numpy array to string conversions are 

1179 surprisingly slow. 

1180 

1181 Parameters 

1182 ------------ 

1183 array : (n,) or (n, d) float or int 

1184 Data to be converted 

1185 If shape is (n,) only column delimiter will be used 

1186 col_delim : str 

1187 What string should separate values in a column 

1188 row_delim : str 

1189 What string should separate values in a row 

1190 digits : int 

1191 How many digits should floating point numbers include 

1192 value_format : str 

1193 Format string for each value or sequence of values 

1194 If multiple values per value_format it must divide 

1195 into array evenly. 

1196 

1197 Returns 

1198 ---------- 

1199 formatted : str 

1200 String representation of original array 

1201 """ 

1202 # convert inputs to correct types 

1203 array = np.asanyarray(array) 

1204 digits = int(digits) 

1205 row_delim = str(row_delim) 

1206 col_delim = str(col_delim) 

1207 value_format = str(value_format) 

1208 

1209 # abort for non-flat arrays 

1210 if len(array.shape) > 1: 

1211 raise ValueError( 

1212 "conversion only works on 1D/2D arrays not %s!", str(array.shape) 

1213 ) 

1214 

1215 # abort for unstructured arrays 

1216 if array.dtype.names is None: 

1217 raise ValueError("array is not structured, use array_to_string instead") 

1218 

1219 # do not allow a value to be repeated in a value format 

1220 if value_format.count("{") > 1: 

1221 raise ValueError( 

1222 "value_format %s is invalid, repeating unstructured array " 

1223 + "values is unsupported", 

1224 value_format, 

1225 ) 

1226 

1227 format_str = "" 

1228 for name in array.dtype.names: 

1229 kind = array[name].dtype.kind 

1230 element_row_length = array[name].shape[1] if len(array[name].shape) == 2 else 1 

1231 if kind in ["i", "u"]: 

1232 # integer types need a no-decimal formatting 

1233 element_format_str = value_format.replace("{}", "{:0.0f}") + col_delim 

1234 elif kind == "f": 

1235 # add the digits formatting to floats 

1236 element_format_str = ( 

1237 value_format.replace("{}", "{:." + str(digits) + "f}") + col_delim 

1238 ) 

1239 else: 

1240 raise ValueError("dtype %s not convertible!", array.dtype) 

1241 format_str += element_row_length * element_format_str 

1242 

1243 # length of extra delimiters at the end 

1244 format_str = format_str[: -len(col_delim)] + row_delim 

1245 # expand format string to whole array 

1246 format_str *= len(array) 

1247 

1248 # loop through flat fields and flatten to single array 

1249 count = len(array) 

1250 # will upgrade everything to a float 

1251 flattened = np.hstack( 

1252 [array[k].reshape((count, -1)) for k in array.dtype.names] 

1253 ).reshape(-1) 

1254 

1255 # run the format operation and remove the extra delimiters 

1256 formatted = format_str.format(*flattened)[: -len(row_delim)] 

1257 

1258 return formatted 

1259 

1260 

1261def array_to_encoded( 

1262 array: ArrayLike, 

1263 dtype: np.typing.DTypeLike | None = None, 

1264 encoding: str = "base64", 

1265) -> Mapping[str, Any]: 

1266 """ 

1267 Export a numpy array to a compact serializable dictionary. 

1268 

1269 Parameters 

1270 ------------ 

1271 array : array 

1272 Any numpy array 

1273 dtype : str or None 

1274 Optional dtype to encode array 

1275 encoding : str 

1276 'base64' or 'binary' 

1277 

1278 Returns 

1279 --------- 

1280 encoded : dict 

1281 Has keys: 

1282 'dtype': str, of dtype 

1283 'shape': tuple of shape 

1284 'base64': str, base64 encoded string 

1285 """ 

1286 array = np.asanyarray(array) 

1287 shape = array.shape 

1288 # ravel also forces contiguous 

1289 flat = np.ravel(array) 

1290 if dtype is None: 

1291 dtype = array.dtype 

1292 

1293 encoded: dict[str, Any] = {"dtype": np.dtype(dtype).str, "shape": shape} 

1294 if encoding in ["base64", "dict64"]: 

1295 packed = base64.b64encode(flat.astype(dtype).tobytes()) 

1296 if hasattr(packed, "decode"): 

1297 packed = packed.decode("utf-8") 

1298 encoded["base64"] = packed 

1299 elif encoding == "binary": 

1300 encoded["binary"] = array.tobytes(order="C") 

1301 else: 

1302 raise ValueError(f"encoding {encoding} is not available!") 

1303 return encoded 

1304 

1305 

1306# the store key type must be `Any` because the key type changes from `bytes` to `str` 

1307def decode_keys(store: dict[Any, Any], encoding: str = "utf-8") -> dict[str, Any]: 

1308 """ 

1309 If a dictionary has keys that are bytes decode them to a str. 

1310 

1311 Parameters 

1312 ------------ 

1313 store : dict 

1314 Dictionary with data 

1315 

1316 Returns 

1317 --------- 

1318 result : dict 

1319 Values are untouched but keys that were bytes 

1320 are converted to ASCII strings. 

1321 

1322 Example 

1323 ----------- 

1324 In [1]: d 

1325 Out[1]: {1020: 'nah', b'hi': 'stuff'} 

1326 

1327 In [2]: trimesh.util.decode_keys(d) 

1328 Out[2]: {1020: 'nah', 'hi': 'stuff'} 

1329 """ 

1330 keys = store.keys() 

1331 for key in keys: 

1332 if hasattr(key, "decode"): 

1333 decoded = key.decode(encoding) 

1334 if key != decoded: 

1335 store[key.decode(encoding)] = store[key] 

1336 store.pop(key) 

1337 return store 

1338 

1339 

1340def comment_strip(text: str, starts_with: str = "#", new_line: str = "\n") -> str: 

1341 """ 

1342 Strip comments from a text block. 

1343 

1344 Parameters 

1345 ----------- 

1346 text : str 

1347 Text to remove comments from 

1348 starts_with : str 

1349 Character or substring that starts a comment 

1350 new_line : str 

1351 Character or substring that ends a comment 

1352 

1353 Returns 

1354 ----------- 

1355 stripped : str 

1356 Text with comments stripped 

1357 """ 

1358 # if not contained exit immediately 

1359 if starts_with not in text: 

1360 return text 

1361 

1362 # start by splitting into chunks by the comment indicator 

1363 split = (text + new_line).split(starts_with) 

1364 

1365 # special case files that start with a comment 

1366 if text.startswith(starts_with): 

1367 lead = "" 

1368 else: 

1369 lead = split[0] 

1370 

1371 # take each comment up until the newline 

1372 removed = [i.split(new_line, 1) for i in split] 

1373 # add the leading string back on 

1374 result = ( 

1375 lead 

1376 + new_line 

1377 + new_line.join(i[1] for i in removed if len(i) > 1 and len(i[1]) > 0) 

1378 ) 

1379 # strip leading and trailing whitespace 

1380 result = result.strip() 

1381 

1382 return result 

1383 

1384 

1385def encoded_to_array(encoded: ArrayLike | dict[Any, Any]) -> NDArray: 

1386 """ 

1387 Turn a dictionary with base64 encoded strings back into a numpy array. 

1388 

1389 Parameters 

1390 ------------ 

1391 encoded 

1392 Has keys: 

1393 dtype: string of dtype 

1394 shape: int tuple of shape 

1395 base64: base64 encoded string of flat array 

1396 binary: decode result coming from numpy.tobytes 

1397 

1398 Returns 

1399 ---------- 

1400 array 

1401 """ 

1402 

1403 if not isinstance(encoded, dict): 

1404 if is_sequence(encoded): 

1405 as_array = np.asanyarray(encoded) 

1406 return as_array 

1407 else: 

1408 raise ValueError("Unable to extract numpy array from input") 

1409 

1410 encoded_dict = decode_keys(encoded) 

1411 

1412 dtype = np.dtype(encoded_dict["dtype"]) 

1413 if "base64" in encoded_dict: 

1414 array = np.frombuffer(base64.b64decode(encoded_dict["base64"]), dtype) 

1415 elif "binary" in encoded_dict: 

1416 array = np.frombuffer(encoded_dict["binary"], dtype=dtype) 

1417 else: 

1418 raise ValueError("Invalid encoded array, no 'base64' or 'binary' key found") 

1419 if "shape" in encoded_dict: 

1420 array = array.reshape(encoded_dict["shape"]) 

1421 return array 

1422 

1423 

1424def is_instance_named(obj: object, name: str | list[str]) -> bool: 

1425 """ 

1426 Given an object, if it is a member of the class 'name', 

1427 or a subclass of 'name', return True. 

1428 

1429 Parameters 

1430 ------------ 

1431 obj : instance 

1432 Some object of some class 

1433 name: str 

1434 The name of the class we want to check for 

1435 

1436 Returns 

1437 --------- 

1438 is_instance : bool 

1439 Whether the object is a member of the named class 

1440 """ 

1441 try: 

1442 if isinstance(name, list): 

1443 return any(is_instance_named(obj, i) for i in name) 

1444 else: 

1445 type_named(obj, name) 

1446 return True 

1447 except ValueError: 

1448 return False 

1449 

1450 

1451def type_bases(obj: object, depth: Integer = 4) -> list[type]: 

1452 """ 

1453 Return the bases of the object passed. 

1454 """ 

1455 bases: collections.deque[list] = collections.deque([list(obj.__class__.__bases__)]) 

1456 for i in range(depth): 

1457 bases.append([i.__base__ for i in bases[-1] if i is not None]) 

1458 try: 

1459 bases_flat = np.hstack(bases) 

1460 except IndexError: 

1461 return [] 

1462 return [i for i in bases_flat if hasattr(i, "__name__")] 

1463 

1464 

1465def type_named(obj: object, name: str) -> type: 

1466 """ 

1467 Similar to the type() builtin, but looks in class bases 

1468 for named instance. 

1469 

1470 Parameters 

1471 ------------ 

1472 obj : any 

1473 Object to look for class of 

1474 name : str 

1475 Name of class 

1476 

1477 Returns 

1478 ---------- 

1479 class : type 

1480 Named class, raises ValueError if not found 

1481 """ 

1482 # if obj is a member of the named class, return True 

1483 name = str(name) 

1484 if obj.__class__.__name__ == name: 

1485 return obj.__class__ 

1486 for base in type_bases(obj): 

1487 if base.__name__ == name: 

1488 return base 

1489 raise ValueError("Unable to extract class of name " + name) 

1490 

1491 

1492def concatenate(a, b=None) -> "trimesh.parent.Geometry": 

1493 """ 

1494 Concatenate two or more meshes. 

1495 

1496 Parameters 

1497 ------------ 

1498 a : trimesh.Trimesh 

1499 Mesh or list of meshes to be concatenated 

1500 object, or list of such 

1501 b : trimesh.Trimesh 

1502 Mesh or list of meshes to be concatenated 

1503 

1504 Returns 

1505 ---------- 

1506 result 

1507 Concatenated mesh 

1508 """ 

1509 dump = [] 

1510 for i in chain(a, b): 

1511 if is_instance_named(i, "Scene"): 

1512 # get every mesh in the final frame. 

1513 dump.extend(i.dump()) 

1514 else: 

1515 # just append to our flat list 

1516 dump.append(i) 

1517 

1518 if len(dump) == 1: 

1519 # if there is only one geometry just return the first 

1520 return dump[0].copy() 

1521 elif len(dump) == 0: 

1522 # if there are no meshes return an empty mesh 

1523 from .base import Trimesh 

1524 

1525 return Trimesh() 

1526 

1527 is_mesh = [f for f in dump if is_instance_named(f, "Trimesh")] 

1528 is_path = [f for f in dump if is_instance_named(f, "Path")] 

1529 

1530 # if we have more 

1531 if len(is_path) > len(is_mesh): 

1532 from .path.util import concatenate as concatenate_path 

1533 

1534 return concatenate_path(is_path) 

1535 

1536 if len(is_mesh) == 0: 

1537 # nothing concatenable was passed — match the empty-input 

1538 # branch above and hand back an empty mesh 

1539 from .base import Trimesh 

1540 

1541 return Trimesh() 

1542 

1543 # extract the trimesh type to avoid a circular import 

1544 # and assert that all inputs are Trimesh objects 

1545 trimesh_type = type_named(is_mesh[0], "Trimesh") 

1546 

1547 # append faces and vertices of meshes 

1548 vertices, faces = append_faces( 

1549 [m.vertices.copy() for m in is_mesh], [m.faces.copy() for m in is_mesh] 

1550 ) 

1551 

1552 # save face normals if already calculated 

1553 face_normals = None 

1554 if any("face_normals" in m._cache for m in is_mesh): 

1555 face_normals = vstack_empty([m.face_normals for m in is_mesh]) 

1556 assert face_normals.shape == faces.shape 

1557 

1558 # save vertex normals if any mesh has them 

1559 vertex_normals = None 

1560 if any("vertex_normals" in m._cache for m in is_mesh): 

1561 vertex_normals = vstack_empty([m.vertex_normals for m in is_mesh]) 

1562 assert vertex_normals.shape == vertices.shape 

1563 

1564 try: 

1565 # concatenate visuals 

1566 visual = is_mesh[0].visual.concatenate([m.visual for m in is_mesh[1:]]) 

1567 except BaseException as E: 

1568 log.debug(f"failed to combine visuals {_STRICT}", exc_info=True) 

1569 visual = None 

1570 if _STRICT: 

1571 raise E 

1572 

1573 metadata = {} 

1574 try: 

1575 _ = [metadata.update(deepcopy(m.metadata) for m in is_mesh)] 

1576 except BaseException: 

1577 pass 

1578 

1579 # concatenate vertex attributes that are valid for every mesh 

1580 vertex_attributes = {} 

1581 for key in is_mesh[0].vertex_attributes.keys(): 

1582 # make sure every mesh has a valid attribute 

1583 if all(len(m.vertex_attributes.get(key, [])) == len(m.vertices) for m in is_mesh): 

1584 try: 

1585 vertex_attributes[key] = np.concatenate( 

1586 [mesh.vertex_attributes.get(key, []) for mesh in is_mesh], axis=0 

1587 ) 

1588 except BaseException: 

1589 log.warning( 

1590 f"Failed to concatenate `vertex_attribute['{key}']`", exc_info=True 

1591 ) 

1592 

1593 # concatenate face attributes that are valid for every mesh 

1594 face_attributes = {} 

1595 for key in is_mesh[0].face_attributes.keys(): 

1596 # an attribute can only be concatenated if it's valid for every mesh 

1597 if all(len(m.face_attributes.get(key, [])) == len(m.faces) for m in is_mesh): 

1598 try: 

1599 # stack along axis 0 

1600 face_attributes[key] = np.concatenate( 

1601 [mesh.face_attributes.get(key, []) for mesh in is_mesh], axis=0 

1602 ) 

1603 except BaseException: 

1604 # could have failed because attribute had different shapes 

1605 log.warning( 

1606 f"Failed to concatenate `face_attribute['{key}']`", exc_info=True 

1607 ) 

1608 

1609 # create the mesh object 

1610 result = trimesh_type( 

1611 vertices=vertices, 

1612 faces=faces, 

1613 face_normals=face_normals, 

1614 vertex_normals=vertex_normals, 

1615 visual=visual, 

1616 vertex_attributes=vertex_attributes, 

1617 face_attributes=face_attributes, 

1618 metadata=metadata, 

1619 process=False, 

1620 ) 

1621 

1622 try: 

1623 result._source = deepcopy(is_mesh[0].source) 

1624 except BaseException: 

1625 pass 

1626 

1627 return result 

1628 

1629 

1630def submesh( 

1631 mesh, 

1632 faces_sequence: Iterable[ArrayLike], 

1633 repair: bool = True, 

1634 only_watertight: bool = False, 

1635 min_faces: Integer | None = None, 

1636 append: bool = False, 

1637): 

1638 """ 

1639 Return a subset of a mesh. 

1640 

1641 Parameters 

1642 ------------ 

1643 mesh : Trimesh 

1644 Source mesh to take geometry from 

1645 faces_sequence : sequence (p,) int 

1646 Indexes of mesh.faces 

1647 repair 

1648 Try to make submeshes watertight 

1649 only_watertight 

1650 Only return submeshes which are watertight 

1651 min_faces 

1652 Minimum number of faces allowed in a submesh. 

1653 append : bool 

1654 Return a single mesh which has the faces appended, 

1655 if this flag is set, only_watertight is ignored 

1656 

1657 Returns 

1658 --------- 

1659 result : Trimesh | list[Trimesh] 

1660 Depending on if `append` is true or not. 

1661 """ 

1662 # evaluate generators so we can escape early 

1663 faces_sequence = list(faces_sequence) 

1664 

1665 if len(faces_sequence) == 0: 

1666 return [] 

1667 

1668 # avoid nuking the cache on the original mesh 

1669 original_faces = mesh.faces.view(np.ndarray) 

1670 original_vertices = mesh.vertices.view(np.ndarray) 

1671 

1672 faces = [] 

1673 vertices = [] 

1674 normals = [] 

1675 visuals = [] 

1676 

1677 # for reindexing faces 

1678 mask = np.arange(len(original_vertices)) 

1679 

1680 for index in faces_sequence: 

1681 # sanitize indices in case they are coming in as a set or tuple 

1682 index = np.asanyarray(index) 

1683 if len(index) == 0: 

1684 # regardless of type empty arrays are useless 

1685 continue 

1686 if index.dtype.kind == "b": 

1687 # if passed a bool with no true continue 

1688 if not index.any(): 

1689 continue 

1690 # if fewer faces than minimum 

1691 if min_faces is not None and index.sum() < min_faces: 

1692 continue 

1693 elif min_faces is not None and len(index) < min_faces: 

1694 continue 

1695 

1696 current = original_faces[index] 

1697 unique = np.unique(current.reshape(-1)) 

1698 

1699 # redefine face indices from zero 

1700 mask[unique] = np.arange(len(unique)) 

1701 normals.append(mesh.face_normals[index]) 

1702 faces.append(mask[current]) 

1703 vertices.append(original_vertices[unique]) 

1704 

1705 visuals.append(mesh.visual.face_subset(index)) 

1706 

1707 if len(vertices) == 0: 

1708 return [] 

1709 

1710 # we use type(mesh) rather than importing Trimesh from base 

1711 # to avoid a circular import 

1712 trimesh_type = type_named(mesh, "Trimesh") 

1713 

1714 if append: 

1715 visual = None 

1716 try: 

1717 visuals = np.array(visuals) 

1718 visual = visuals[0].concatenate(visuals[1:]) 

1719 except Exception: 

1720 log.debug("failed to combine visuals", exc_info=True) 

1721 # re-index faces and stack 

1722 vertices, faces = append_faces(vertices, faces) 

1723 appended = trimesh_type( 

1724 vertices=vertices, 

1725 faces=faces, 

1726 face_normals=np.vstack(normals), 

1727 visual=visual, 

1728 metadata=deepcopy(mesh.metadata), 

1729 process=False, 

1730 ) 

1731 appended._source = deepcopy(mesh.source) 

1732 

1733 return appended 

1734 

1735 if visuals is None: 

1736 visuals = [None] * len(vertices) 

1737 

1738 # generate a list of Trimesh objects 

1739 result = [ 

1740 trimesh_type( 

1741 vertices=v, 

1742 faces=f, 

1743 face_normals=n, 

1744 visual=c, 

1745 metadata=deepcopy(mesh.metadata), 

1746 process=False, 

1747 ) 

1748 for v, f, n, c in zip(vertices, faces, normals, visuals) 

1749 ] 

1750 

1751 # assign the "source" information summarizing where a mesh was 

1752 # loaded from (i.e. file name) to each submesh of the result 

1753 [setattr(r, "_source", deepcopy(mesh.source)) for r in result] 

1754 

1755 if repair: 

1756 # fill_holes will attempt a repair and returns the 

1757 # watertight status at the end of the repair attempt 

1758 watertight = [len(i.faces) >= 4 and i.fill_holes() for i in result] 

1759 elif only_watertight: 

1760 # calculate watertightness without repairing 

1761 watertight = [i.is_watertight for i in result] 

1762 

1763 if only_watertight: 

1764 # return only the watertight meshes 

1765 return [i for i, w in zip(result, watertight) if w] 

1766 

1767 return result 

1768 

1769 

1770def zero_pad(data: NDArray, count: Integer, right: bool = True) -> NDArray: 

1771 """ 

1772 Parameters 

1773 ------------ 

1774 data : (n,) 

1775 1D array 

1776 count : int 

1777 Minimum length of result array 

1778 

1779 Returns 

1780 --------- 

1781 padded : (m,) 

1782 1D array where m >= count 

1783 """ 

1784 if len(data) == 0: 

1785 return np.zeros(count) 

1786 elif len(data) < count: 

1787 padded = np.zeros(count) 

1788 if right: 

1789 padded[-len(data) :] = data 

1790 else: 

1791 padded[: len(data)] = data 

1792 return padded 

1793 else: 

1794 return np.asanyarray(data) 

1795 

1796 

1797def jsonify(obj: object, **kwargs: Any) -> str: 

1798 """ 

1799 A version of json.dumps that can handle numpy arrays 

1800 by creating a custom encoder for numpy dtypes. 

1801 

1802 Parameters 

1803 -------------- 

1804 obj : list, dict 

1805 A JSON-serializable blob 

1806 kwargs : dict 

1807 Passed to json.dumps 

1808 

1809 Returns 

1810 -------------- 

1811 dumped : str 

1812 JSON dump of obj 

1813 """ 

1814 

1815 class EdgeEncoder(json.JSONEncoder): 

1816 def default(self, obj: Any): 

1817 # will work for numpy.ndarrays 

1818 # as well as their int64/etc objects 

1819 if hasattr(obj, "tolist"): 

1820 # this works on numpy arrays 

1821 return obj.tolist() 

1822 elif hasattr(obj, "timestamp"): 

1823 # serialize datetime as the float stamp 

1824 return obj.timestamp() 

1825 elif hasattr(obj, "__dataclass_fields__"): 

1826 # serialize dataclasses as dict 

1827 return dataclass_to_dict(obj) 

1828 return json.JSONEncoder.default(self, obj) 

1829 

1830 # run the dumps using our encoder 

1831 return json.dumps(obj, cls=EdgeEncoder, **kwargs) 

1832 

1833 

1834def convert_like(item, like): 

1835 """ 

1836 Convert an item to have the dtype of another item 

1837 

1838 Parameters 

1839 ------------ 

1840 item : any 

1841 Item to be converted 

1842 like : any 

1843 Object with target dtype 

1844 If None, item is returned unmodified 

1845 

1846 Returns 

1847 ---------- 

1848 result: item, but in dtype of like 

1849 """ 

1850 # if it's a numpy array 

1851 if isinstance(like, np.ndarray): 

1852 return np.asanyarray(item, dtype=like.dtype) 

1853 

1854 # if it's already the desired type just return it 

1855 if isinstance(item, like.__class__) or like is None: 

1856 return item 

1857 

1858 # if it's an array with one item return it 

1859 if is_sequence(item) and len(item) == 1 and isinstance(item[0], like.__class__): 

1860 return item[0] 

1861 

1862 if ( 

1863 isinstance(item, str) 

1864 and like.__class__.__name__ == "Polygon" 

1865 and item.startswith("POLYGON") 

1866 ): 

1867 # break our rule on imports but only a little bit 

1868 # the import was a WKT serialized polygon 

1869 from shapely import wkt 

1870 

1871 return wkt.loads(item) 

1872 

1873 # otherwise just run the conversion 

1874 item = like.__class__(item) 

1875 

1876 return item 

1877 

1878 

1879def bounds_tree(bounds: ArrayLike) -> Any: 

1880 """ 

1881 Given a set of axis aligned bounds create an r-tree for 

1882 broad-phase collision detection. 

1883 

1884 Parameters 

1885 ------------ 

1886 bounds : (n, 2D) or (n, 2, D) float 

1887 Non-interleaved bounds where D=dimension 

1888 E.G a 2D bounds tree: 

1889 [(minx, miny, maxx, maxy), ...] 

1890 

1891 Returns 

1892 --------- 

1893 tree : Rtree 

1894 Tree containing bounds by index 

1895 """ 

1896 import rtree 

1897 

1898 # make sure we've copied bounds 

1899 bounds = np.array(bounds, dtype=np.float64, copy=True) 

1900 if len(bounds.shape) == 3: 

1901 # should be min-max per bound 

1902 if bounds.shape[1] != 2: 

1903 raise ValueError("bounds not (n, 2, dimension)!") 

1904 # reshape to one-row-per-hyperrectangle 

1905 bounds = bounds.reshape((len(bounds), -1)) 

1906 elif len(bounds.shape) != 2 or bounds.size == 0: 

1907 raise ValueError("Bounds must be (n, dimension * 2)!") 

1908 

1909 # check to make sure we have correct shape 

1910 dimension = bounds.shape[1] 

1911 if (dimension % 2) != 0: 

1912 raise ValueError("Bounds must be (n,dimension*2)!") 

1913 dimension = int(dimension / 2) 

1914 

1915 properties = rtree.index.Property(dimension=dimension) 

1916 # stream load was verified working on import above 

1917 return rtree.index.Index( 

1918 zip(np.arange(len(bounds)), bounds, [None] * len(bounds)), properties=properties 

1919 ) 

1920 

1921 

1922def wrap_as_stream(item: str | bytes) -> StringIO | BytesIO: 

1923 """ 

1924 Wrap a string or bytes object as a file object. 

1925 

1926 Parameters 

1927 ------------ 

1928 item: str or bytes 

1929 Item to be wrapped 

1930 

1931 Returns 

1932 --------- 

1933 wrapped : file-like object 

1934 Contains data from item 

1935 """ 

1936 if isinstance(item, str): 

1937 return StringIO(item) 

1938 elif isinstance(item, bytes): 

1939 return BytesIO(item) 

1940 raise ValueError(f"{type(item).__name__} is not wrappable!") 

1941 

1942 

1943def sigfig_round(values: ArrayLike, sigfig: ArrayLike = 1) -> NDArray1D[np.float64]: 

1944 """ 

1945 Round a single value to a specified number of significant figures. 

1946 

1947 Parameters 

1948 ------------ 

1949 values : float 

1950 Value to be rounded 

1951 sigfig : int 

1952 Number of significant figures to reduce to 

1953 

1954 Returns 

1955 ---------- 

1956 rounded : float 

1957 Value rounded to the specified number of significant figures 

1958 

1959 

1960 Examples 

1961 ---------- 

1962 In [1]: trimesh.util.round_sigfig(-232453.00014045456, 1) 

1963 Out[1]: -200000.0 

1964 

1965 In [2]: trimesh.util.round_sigfig(.00014045456, 1) 

1966 Out[2]: 0.0001 

1967 

1968 In [3]: trimesh.util.round_sigfig(.00014045456, 4) 

1969 Out[3]: 0.0001405 

1970 """ 

1971 as_int, multiplier = sigfig_int(values, sigfig) 

1972 rounded = as_int * (10**multiplier) 

1973 

1974 return rounded 

1975 

1976 

1977def sigfig_int( 

1978 values: ArrayLike, sigfig: ArrayLike 

1979) -> tuple[NDArray1D[np.int64], NDArray1D[np.float64]]: 

1980 """ 

1981 Convert a set of floating point values into integers 

1982 with a specified number of significant figures and an 

1983 exponent. 

1984 

1985 Parameters 

1986 ------------ 

1987 values : (n,) float or int 

1988 Array of values 

1989 sigfig : (n,) int 

1990 Number of significant figures to keep 

1991 

1992 Returns 

1993 ------------ 

1994 as_int : (n,) int 

1995 Every value[i] has sigfig[i] digits 

1996 multiplier : (n,) float 

1997 Exponent, so as_int * 10 ** multiplier is 

1998 the same order of magnitude as the input 

1999 """ 

2000 values = np.asanyarray(values).reshape(-1) 

2001 sigfig = np.asanyarray(sigfig, dtype=np.int64).reshape(-1) 

2002 

2003 if sigfig.shape != values.shape: 

2004 raise ValueError("sigfig must match identifier") 

2005 

2006 exponent = np.zeros(len(values)) 

2007 nonzero = np.abs(values) > TOL_ZERO 

2008 exponent[nonzero] = np.floor(np.log10(np.abs(values[nonzero]))) 

2009 

2010 multiplier = exponent - sigfig + 1 

2011 as_int = (values / (10**multiplier)).round().astype(np.int64) 

2012 

2013 return as_int, multiplier 

2014 

2015 

2016# skip ZIP members once declared uncompressed sizes exceed this 

2017_ARCHIVE_SKIP_SIZE = 32 * 1024**3 # 32 GiB 

2018 

2019 

2020def decompress( 

2021 file_obj: bytes | Stream, 

2022 file_type: str, 

2023) -> dict[str, Stream | None]: 

2024 """ 

2025 Given an open file object and a file type, return all components 

2026 of the archive as open file objects in a dict. 

2027 

2028 ZIP members are skipped once the total uncompressed size declared by 

2029 the archive exceeds `_ARCHIVE_SKIP_SIZE`. Other formats don't store a 

2030 per-member compressed size so there is nothing to check before reading. 

2031 

2032 Parameters 

2033 ------------ 

2034 file_obj : file-like 

2035 Containing compressed data. 

2036 file_type : str 

2037 File extension, 'zip', 'tar.gz', etc. 

2038 

2039 Returns 

2040 --------- 

2041 decompressed : dict 

2042 Data from archive in format {file name : file-like}. 

2043 """ 

2044 file_type = str(file_type).lower() 

2045 if isinstance(file_obj, bytes): 

2046 file_obj = BytesIO(file_obj) 

2047 

2048 if file_type.endswith("zip"): 

2049 archive = zipfile.ZipFile(file_obj) 

2050 result = {} 

2051 # running total of the sizes the central directory declared: this is 

2052 # an upper bound on what we can actually read as `ZipExtFile` stops 

2053 # at the declared size and then fails the CRC check 

2054 total = 0 

2055 for info in archive.infolist(): 

2056 if total + info.file_size > _ARCHIVE_SKIP_SIZE: 

2057 log.warning( 

2058 "skipping `%s`: declared %d bytes exceeds archive budget", 

2059 info.filename, 

2060 info.file_size, 

2061 ) 

2062 continue 

2063 total += info.file_size 

2064 with archive.open(info, mode="r") as src: 

2065 data = src.read() 

2066 result[info.filename] = wrap_as_stream(data) 

2067 return result 

2068 if file_type.endswith("bz2"): 

2069 import bz2 

2070 

2071 # get the file name if we have one otherwise default to "archive" 

2072 name = getattr(file_obj, "name", "archive1234")[:-4] 

2073 return {name: wrap_as_stream(bz2.open(file_obj, mode="r").read())} 

2074 if "tar" in file_type[-6:]: 

2075 import tarfile 

2076 

2077 archive = tarfile.open(fileobj=file_obj, mode="r") 

2078 result = {} 

2079 for info in archive.getmembers(): 

2080 if not info.isfile(): 

2081 continue 

2082 src = archive.extractfile(info) 

2083 if src is None: 

2084 continue 

2085 result[info.name] = wrap_as_stream(src.read()) 

2086 return result 

2087 raise ValueError("Unsupported type passed!") 

2088 

2089 

2090def compress( 

2091 info: Mapping[str, str | bytes | Stream], 

2092 **kwargs: Any, 

2093) -> bytes: 

2094 """ 

2095 Compress data stored in a dict. 

2096 

2097 Parameters 

2098 ----------- 

2099 info : dict 

2100 Data to compress in form: 

2101 {file name in archive: bytes or file-like object} 

2102 kwargs : dict 

2103 Passed to zipfile.ZipFile 

2104 Returns 

2105 ----------- 

2106 compressed : bytes 

2107 Compressed file data 

2108 """ 

2109 file_obj = BytesIO() 

2110 with zipfile.ZipFile( 

2111 file_obj, mode="w", compression=zipfile.ZIP_DEFLATED, **kwargs 

2112 ) as zipper: 

2113 for name, data_or_file in info.items(): 

2114 if isinstance(data_or_file, (str, bytes)): 

2115 data = data_or_file 

2116 else: 

2117 # a file-like object — read its contents 

2118 data = data_or_file.read() 

2119 zipper.writestr(name, data) 

2120 file_obj.seek(0) 

2121 compressed = file_obj.read() 

2122 return compressed 

2123 

2124 

2125def split_extension(file_name: str, special: Iterable[str] | None = None) -> str: 

2126 """ 

2127 Find the file extension of a file name, including support for 

2128 special case multipart file extensions (like .tar.gz) 

2129 

2130 Parameters 

2131 ------------ 

2132 file_name : str 

2133 File name 

2134 special : list of str 

2135 Multipart extensions 

2136 eg: ['tar.bz2', 'tar.gz'] 

2137 

2138 Returns 

2139 ---------- 

2140 extension : str 

2141 Last characters after a period, or 

2142 a value from 'special' 

2143 """ 

2144 file_name = str(file_name) 

2145 

2146 if special is None: 

2147 special = ["tar.bz2", "tar.gz"] 

2148 if file_name.endswith(tuple(special)): 

2149 for end in special: 

2150 if file_name.endswith(end): 

2151 return end 

2152 return file_name.split(".")[-1] 

2153 

2154 

2155def triangle_strips_to_faces( 

2156 strips: ArrayLike | Sequence[ArrayLike], 

2157) -> NDArray2D[np.int64]: 

2158 """ 

2159 Convert a sequence of triangle strips to (n, 3) faces. 

2160 

2161 Processes all strips at once using np.concatenate and is significantly 

2162 faster than loop-based methods. 

2163 

2164 From the OpenGL programming guide describing a single triangle 

2165 strip [v0, v1, v2, v3, v4]: 

2166 

2167 Draws a series of triangles (three-sided polygons) using vertices 

2168 v0, v1, v2, then v2, v1, v3 (note the order), then v2, v3, v4, 

2169 and so on. The ordering is to ensure that the triangles are all 

2170 drawn with the same orientation so that the strip can correctly form 

2171 part of a surface. 

2172 

2173 Parameters 

2174 ------------ 

2175 strips: (n,) list of (m,) int 

2176 Vertex indices 

2177 

2178 Returns 

2179 ------------ 

2180 faces : (m, 3) int 

2181 Vertex indices representing triangles 

2182 """ 

2183 

2184 # save the length of each list in the list of lists 

2185 lengths = np.array([len(i) for i in strips], dtype=np.int64) 

2186 # looping through a list of lists is extremely slow 

2187 # combine all the sequences into a blob we can manipulate 

2188 blob = np.concatenate(strips, dtype=np.int64) 

2189 

2190 # slice the blob into rough triangles 

2191 tri = np.array([blob[:-2], blob[1:-1], blob[2:]], dtype=np.int64).T 

2192 

2193 # if we only have one strip we can do a *lot* less work 

2194 # as we keep every triangle and flip every other one 

2195 if len(strips) == 1: 

2196 # flip in-place every other triangle 

2197 tri[1::2] = np.fliplr(tri[1::2]) 

2198 return tri 

2199 

2200 # remove the triangles which were implicit but not actually there 

2201 # because we combined everything into one big array for speed 

2202 length_index = np.cumsum(lengths)[:-1] 

2203 keep = np.ones(len(tri), dtype=bool) 

2204 keep[length_index - 2] = False 

2205 keep[length_index - 1] = False 

2206 tri = tri[keep] 

2207 

2208 # flip every other triangle so they generate correct normals/winding 

2209 length_index = np.append(0, np.cumsum(lengths - 2)) 

2210 flip = np.zeros(length_index[-1], dtype=bool) 

2211 for i in range(len(length_index) - 1): 

2212 flip[length_index[i] + 1 : length_index[i + 1]][::2] = True 

2213 tri[flip] = np.fliplr(tri[flip]) 

2214 

2215 return tri 

2216 

2217 

2218def triangle_fans_to_faces( 

2219 fans: Iterable[ArrayLike], 

2220) -> NDArray2D[np.int64]: 

2221 """ 

2222 Convert fans of m + 2 vertex indices in fan format to m triangles 

2223 

2224 Parameters 

2225 ---------- 

2226 fans: (n,) list of (m + 2,) int 

2227 Vertex indices 

2228 

2229 Returns 

2230 ------- 

2231 faces: (m, 3) int 

2232 Vertex indices representing triangles 

2233 """ 

2234 

2235 faces = [ 

2236 np.transpose([fan[0] * np.ones(len(fan) - 2, dtype=int), fan[1:-1], fan[2:]]) 

2237 for fan in fans 

2238 ] 

2239 return np.concatenate(faces, dtype=int) 

2240 

2241 

2242def vstack_empty(tup: Iterable[ArrayLike]) -> NDArray: 

2243 """ 

2244 A thin wrapper for numpy.vstack that ignores empty lists. 

2245 

2246 Parameters 

2247 ------------ 

2248 tup : tuple or list of arrays 

2249 With the same number of columns 

2250 

2251 Returns 

2252 ------------ 

2253 stacked : (n, d) array 

2254 With same number of columns as 

2255 constituent arrays. 

2256 """ 

2257 # filter out empty arrays 

2258 stackable = [i for i in tup if len(i) > 0] 

2259 # if we only have one array just return it 

2260 if len(stackable) == 1: 

2261 return np.asanyarray(stackable[0]) 

2262 # if we have nothing return an empty numpy array 

2263 elif len(stackable) == 0: 

2264 return np.array([]) 

2265 # otherwise just use vstack as normal 

2266 return np.vstack(stackable) 

2267 

2268 

2269def write_encoded( 

2270 file_obj: Stream, 

2271 stuff: str | bytes, 

2272 encoding: str = "utf-8", 

2273) -> str | bytes: 

2274 """ 

2275 If a file is open in binary mode and a 

2276 string is passed, encode and write. 

2277 

2278 If a file is open in text mode and bytes are 

2279 passed decode bytes to str and write. 

2280 

2281 Assumes binary mode if file_obj does not have 

2282 a 'mode' attribute (e.g. io.BufferedRandom). 

2283 

2284 Parameters 

2285 ----------- 

2286 file_obj : file object 

2287 With 'write' and 'mode' 

2288 stuff : str or bytes 

2289 Stuff to be written 

2290 encoding : str 

2291 Encoding of text 

2292 """ 

2293 binary_file = "b" in getattr(file_obj, "mode", "b") 

2294 string_stuff = isinstance(stuff, str) 

2295 binary_stuff = isinstance(stuff, bytes) 

2296 

2297 if binary_file and string_stuff: 

2298 file_obj.write(stuff.encode(encoding)) 

2299 elif not binary_file and binary_stuff: 

2300 file_obj.write(stuff.decode(encoding)) 

2301 else: 

2302 file_obj.write(stuff) 

2303 file_obj.flush() 

2304 return stuff 

2305 

2306 

2307def unique_id(length: Integer = 12) -> str: 

2308 """ 

2309 Generate a random alphaNumber unique identifier 

2310 using UUID logic. 

2311 

2312 Parameters 

2313 ------------ 

2314 length : int 

2315 Length of desired identifier 

2316 

2317 Returns 

2318 ------------ 

2319 unique : str 

2320 Unique alphaNumber identifier 

2321 """ 

2322 return uuid.UUID(int=random.getrandbits(128), version=4).hex[:length] 

2323 

2324 

2325def generate_basis(z: ArrayLike, epsilon: float = 1e-12) -> NDArray2D[np.float64]: 

2326 """ 

2327 Generate an arbitrary basis (also known as a coordinate frame) 

2328 from a given z-axis vector. 

2329 

2330 Parameters 

2331 ------------ 

2332 z : (3,) float 

2333 A vector along the positive z-axis. 

2334 epsilon : float 

2335 Numbers smaller than this considered zero. 

2336 

2337 Returns 

2338 --------- 

2339 x : (3,) float 

2340 Vector along x axis. 

2341 y : (3,) float 

2342 Vector along y axis. 

2343 z : (3,) float 

2344 Vector along z axis. 

2345 """ 

2346 # get a copy of input vector 

2347 z = np.array(z, dtype=np.float64, copy=True) 

2348 # must be a 3D vector 

2349 if z.shape != (3,): 

2350 raise ValueError("z must be (3,) float!") 

2351 

2352 z_norm = np.linalg.norm(z) 

2353 if z_norm < epsilon: 

2354 return np.eye(3) 

2355 

2356 # normalize vector in-place 

2357 z /= z_norm 

2358 # X as arbitrary perpendicular vector 

2359 x = np.array([-z[1], z[0], 0.0]) 

2360 # avoid degenerate case 

2361 x_norm = np.linalg.norm(x) 

2362 if x_norm < epsilon: 

2363 # this means that 

2364 # so a perpendicular X is just X 

2365 x = np.array([-z[2], z[1], 0.0]) 

2366 x /= np.linalg.norm(x) 

2367 else: 

2368 # otherwise normalize X in-place 

2369 x /= x_norm 

2370 # get perpendicular Y with cross product 

2371 y = np.cross(z, x) 

2372 # append result values into (3, 3) vector 

2373 result = np.array([x, y, z], dtype=np.float64) 

2374 

2375 if _STRICT: 

2376 # run checks to make sure axis are perpendicular 

2377 assert np.abs(np.dot(x, z)) < 1e-8 

2378 assert np.abs(np.dot(y, z)) < 1e-8 

2379 assert np.abs(np.dot(x, y)) < 1e-8 

2380 # all vectors should be unit vector 

2381 assert np.allclose(np.linalg.norm(result, axis=1), 1.0) 

2382 

2383 return result 

2384 

2385 

2386def isclose( 

2387 a: Number | NDArray, 

2388 b: Number | NDArray, 

2389 atol: Floating = 1e-8, 

2390) -> np.bool_ | NDArray[np.bool_]: 

2391 """ 

2392 A replacement for np.isclose that does fewer checks 

2393 and validation and as a result is roughly 4x faster. 

2394 

2395 Note that this is used in tight loops, and as such 

2396 a and b MUST be np.ndarray, not list or "array-like" 

2397 

2398 Parameters 

2399 ------------ 

2400 a : np.ndarray 

2401 To be compared 

2402 b : np.ndarray 

2403 To be compared 

2404 atol : float 

2405 Acceptable distance between `a` and `b` to be "close" 

2406 

2407 Returns 

2408 ----------- 

2409 close : np.ndarray, bool 

2410 Per-element closeness 

2411 """ 

2412 diff = a - b 

2413 return np.logical_and(diff > -atol, diff < atol) 

2414 

2415 

2416def allclose(a: Number | NDArray, b: Number | NDArray, atol: Floating = 1e-8) -> bool: 

2417 """ 

2418 A replacement for np.allclose that does few checks 

2419 and validation and as a result is faster. 

2420 

2421 Parameters 

2422 ------------ 

2423 a : np.ndarray 

2424 To be compared 

2425 b : np.ndarray 

2426 To be compared 

2427 atol : float 

2428 Acceptable distance between `a` and `b` to be "close" 

2429 

2430 Returns 

2431 ----------- 

2432 bool indicating if all elements are within `atol`. 

2433 """ 

2434 # 

2435 return bool(float(np.ptp(a - b)) < atol) 

2436 

2437 

2438class FunctionRegistry(Mapping[str, Callable[..., Any]]): 

2439 """ 

2440 Non-overwritable mapping of string keys to functions. 

2441 

2442 This allows external packages to register additional implementations 

2443 of common functionality without risk of breaking implementations provided 

2444 by trimesh. 

2445 

2446 See trimesh.voxel.morphology for example usage. 

2447 """ 

2448 

2449 def __init__(self, **kwargs: Callable[..., Any]) -> None: 

2450 self._dict = {} 

2451 for k, v in kwargs.items(): 

2452 self[k] = v 

2453 

2454 def __getitem__(self, key: str) -> Callable[..., Any]: 

2455 return self._dict[key] 

2456 

2457 def __setitem__(self, key: str, value: Callable[..., object]) -> None: 

2458 if not isinstance(key, str): 

2459 raise ValueError(f"key must be a string, got {key!s}") 

2460 if key in self: 

2461 raise KeyError(f"Cannot set new value to existing key {key}") 

2462 if not callable(value): 

2463 raise ValueError("Cannot set value which is not callable.") 

2464 self._dict[key] = value 

2465 

2466 def __iter__(self) -> Iterator[str]: 

2467 return iter(self._dict) 

2468 

2469 def __len__(self) -> int: 

2470 return len(self._dict) 

2471 

2472 def __contains__(self, key: object) -> bool: 

2473 return key in self._dict 

2474 

2475 def __call__(self, key: str, *args: object, **kwargs: object) -> Any: 

2476 return self[key](*args, **kwargs) 

2477 

2478 

2479def decode_text(text: str | bytes, initial: str = "utf-8") -> str: 

2480 """ 

2481 Try to decode byte input as a string. 

2482 

2483 Tries initial guess (UTF-8) then if that fails it 

2484 uses charset_normalizer to try another guess before failing. 

2485 

2486 Parameters 

2487 ------------ 

2488 text : bytes 

2489 Data that might be a string 

2490 initial : str 

2491 Initial guess for text encoding. 

2492 

2493 Returns 

2494 ------------ 

2495 decoded : str 

2496 Data as a string 

2497 """ 

2498 # if it's already a string there is nothing to decode 

2499 if isinstance(text, str): 

2500 return text 

2501 

2502 try: 

2503 # initially guess file is UTF-8 or specified encoding 

2504 return text.decode(initial) 

2505 except UnicodeDecodeError: 

2506 # detect different file encodings 

2507 from charset_normalizer import detect as charset_normalizer_detect 

2508 

2509 # try to detect the encoding of the file 

2510 # only look at the first 1000 characters for speed 

2511 detect = charset_normalizer_detect(text[:1000]) 

2512 # warn on files that aren't UTF-8 

2513 log.debug( 

2514 "Data not {}! Trying {} (confidence {})".format( 

2515 initial, detect["encoding"], detect["confidence"] 

2516 ) 

2517 ) 

2518 # try to decode again ignoring errors 

2519 # if detect returned nothing just use the initial guess 

2520 return text.decode(detect["encoding"] or initial, errors="ignore") 

2521 

2522 

2523def to_ascii(text: Any) -> str: 

2524 """ 

2525 Force a string or other to ASCII text ignoring errors. 

2526 

2527 Parameters 

2528 ----------- 

2529 text : any 

2530 Input to be converted to ASCII string 

2531 

2532 Returns 

2533 ----------- 

2534 ascii : str 

2535 Input as an ASCII string 

2536 """ 

2537 if hasattr(text, "encode"): 

2538 # case for existing strings 

2539 return text.encode("ascii", errors="ignore").decode("ascii") 

2540 elif hasattr(text, "decode"): 

2541 # case for bytes 

2542 return text.decode("ascii", errors="ignore") 

2543 # otherwise just wrap as a string 

2544 return str(text) 

2545 

2546 

2547def is_ccw(points: ArrayLike, return_all: bool = False): 

2548 """ 

2549 Check if connected 2D points are counterclockwise. 

2550 

2551 Parameters 

2552 ----------- 

2553 points : (n, 2) float 

2554 Connected points on a plane 

2555 return_all : bool 

2556 Return polygon area and centroid or just counter-clockwise. 

2557 

2558 Returns 

2559 ---------- 

2560 ccw : bool 

2561 True if points are counter-clockwise 

2562 area : float 

2563 Only returned if `return_centroid` 

2564 centroid : (2,) float 

2565 Centroid of the polygon. 

2566 """ 

2567 points = np.array(points, dtype=np.float64) 

2568 

2569 if len(points.shape) != 2 or points.shape[1] != 2: 

2570 raise ValueError("only defined for `(n, 2)` points") 

2571 

2572 # the "shoelace formula" 

2573 product = np.subtract(*(points[:-1, [1, 0]] * points[1:]).T) 

2574 # the area of the polygon 

2575 area = product.sum() / 2.0 

2576 # check the sign of the area 

2577 ccw = area < 0.0 

2578 

2579 if not return_all: 

2580 return ccw 

2581 

2582 # the centroid of the polygon uses the same formula 

2583 centroid = ((points[:-1] + points[1:]) * product.reshape((-1, 1))).sum(axis=0) / ( 

2584 6.0 * area 

2585 ) 

2586 

2587 return ccw, area, centroid 

2588 

2589 

2590def unique_name( 

2591 start: str | None, 

2592 contains: Collection[str], 

2593 counts: MutableMapping[str | None, int] | None = None, 

2594) -> str: 

2595 """ 

2596 Deterministically generate a unique name not 

2597 contained in a dict, set or other grouping with 

2598 `__includes__` defined. Will create names of the 

2599 form "start_10" and increment accordingly. 

2600 

2601 Parameters 

2602 ----------- 

2603 start : str 

2604 Initial guess for name. 

2605 contains : dict, set, or list 

2606 Bundle of existing names we can *not* use. 

2607 counts : None or dict 

2608 Maps name starts encountered before to increments in 

2609 order to speed up finding a unique name as otherwise 

2610 it potentially has to iterate through all of contains. 

2611 Should map to "how many times has this `start` 

2612 been attempted, i.e. `counts[start]: int`. 

2613 Note that this *will be mutated* in-place by this function! 

2614 

2615 Returns 

2616 --------- 

2617 unique : str 

2618 A name that is not contained in `contains` 

2619 """ 

2620 # exit early if name is not in bundle 

2621 if start is not None and len(start) > 0 and start not in contains: 

2622 return start 

2623 

2624 # start checking with zero index unless found 

2625 if counts is None: 

2626 increment = 0 

2627 else: 

2628 increment = counts.get(start, 0) 

2629 if start is not None and len(start) > 0: 

2630 formatter = start + "_{}" 

2631 # split by our delimiter once 

2632 split = start.rsplit("_", 1) 

2633 if len(split) == 2 and increment == 0: 

2634 try: 

2635 # start incrementing from the existing 

2636 # trailing value 

2637 # if it is not an integer this will fail 

2638 increment = int(split[1]) 

2639 # include the first split value 

2640 formatter = split[0] + "_{}" 

2641 except BaseException: 

2642 pass 

2643 else: 

2644 formatter = "geometry_{}" 

2645 

2646 # if contains is empty we will only need to check once 

2647 for i in range(increment + 1, 2 + increment + len(contains)): 

2648 check = formatter.format(i) 

2649 if check not in contains: 

2650 if counts is not None: 

2651 counts[start] = i 

2652 return check 

2653 

2654 # this should really never happen since we looped 

2655 # through the full length of contains 

2656 raise ValueError("Unable to establish unique name!")