Coverage for trimesh/util.py: 84%

786 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-24 04:40 +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 io import BytesIO, StringIO 

28from typing import TYPE_CHECKING 

29 

30import numpy as np 

31 

32from .iteration import chain 

33 

34# use our wrapped types for wider version compatibility 

35from .typed import ( 

36 Any, 

37 ArrayLike, 

38 BoolIsFile, 

39 Floating, 

40 Integer, 

41 Iterable, 

42 NDArray, 

43 NDArray1D, 

44 NDArray2D, 

45 Number, 

46 Stream, 

47) 

48 

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

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

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

52if TYPE_CHECKING: 

53 import trimesh.parent 

54 

55# create a default logger 

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

57 

58ABC = abc.ABC 

59now = time.time 

60which = shutil.which 

61 

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

63# a floating point threshold for 0.0 

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

65# which works out to be 1e-13 

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

67# how close to merge vertices 

68TOL_MERGE: float = 1e-8 

69# enable additional potentially slow checks 

70_STRICT: bool = False 

71 

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

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

74_IDENTITY.flags["WRITEABLE"] = False 

75 

76 

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

78 """ 

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

80 actually importing the module. 

81 

82 Parameters 

83 ------------ 

84 name : str 

85 The name of the module to check 

86 

87 Returns 

88 ------------ 

89 installed : bool 

90 True if module is installed 

91 """ 

92 from importlib.util import find_spec 

93 

94 return find_spec(name) is not None 

95 

96 

97def unitize( 

98 vectors: ArrayLike, 

99 check_valid: bool = False, 

100 threshold: float | None = None, 

101): 

102 """ 

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

104 

105 Parameters 

106 ------------ 

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

108 Vector or vectors to be unitized 

109 check_valid : bool 

110 If set, will return mask of nonzero vectors 

111 threshold : float 

112 Cutoff for a value to be considered zero. 

113 

114 Returns 

115 --------- 

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

117 Input vectors but unitized 

118 valid : (n,) bool or bool 

119 Mask of nonzero vectors returned if `check_valid` 

120 """ 

121 # make sure we have a numpy array 

122 vectors = np.asanyarray(vectors) 

123 

124 # allow user to set zero threshold 

125 if threshold is None: 

126 threshold = TOL_ZERO 

127 

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

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

130 # using sqrt and avoiding exponents is slightly faster 

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

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

133 # non-zero norms 

134 valid = norm > threshold 

135 # in-place reciprocal of nonzero norms 

136 norm[valid] **= -1 

137 # multiply by reciprocal of norm 

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

139 

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

141 # treat 1D arrays as a single vector 

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

143 valid = norm > threshold 

144 if valid: 

145 unit = vectors / norm 

146 else: 

147 unit = vectors.copy() 

148 else: 

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

150 

151 if check_valid: 

152 return unit[valid], valid 

153 return unit 

154 

155 

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

157 """ 

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

159 """ 

160 warnings.warn( 

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

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

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

164 category=DeprecationWarning, 

165 stacklevel=2, 

166 ) 

167 

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

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

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

171 

172 

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

174 """ 

175 Check if an object is file-like 

176 

177 Parameters 

178 ------------ 

179 obj : object 

180 Any object type to be checked 

181 

182 Returns 

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

184 is_file : bool 

185 True if object is a file 

186 """ 

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

188 

189 

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

191 """ 

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

193 

194 Parameters 

195 ------------ 

196 obj : object 

197 Object to be checked 

198 

199 Returns 

200 ------------ 

201 is_pathlib : bool 

202 Is the input object a pathlib path 

203 """ 

204 # check class name rather than a pathlib import 

205 name = obj.__class__.__name__ 

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

207 

208 

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

210 """ 

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

212 

213 Replace with `isinstance(obj, str)` 

214 """ 

215 warnings.warn( 

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

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

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

219 category=DeprecationWarning, 

220 stacklevel=2, 

221 ) 

222 

223 return isinstance(obj, str) 

224 

225 

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

227 """ 

228 Check if an object is a sequence or not. 

229 

230 Parameters 

231 ------------- 

232 obj : object 

233 Any object type to be checked 

234 

235 Returns 

236 ------------- 

237 is_sequence : bool 

238 True if object is sequence 

239 """ 

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

241 obj, "__iter__" 

242 ) 

243 

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

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

246 

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

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

249 

250 # numpy sometimes returns objects that are single float64 values 

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

252 if hasattr(obj, "shape"): 

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

254 

255 return seq 

256 

257 

258def is_shape( 

259 obj: NDArray | Any, 

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

261 allow_zeros: bool = False, 

262) -> bool: 

263 """ 

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

265 with any value less than zero being considered a wildcard 

266 

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

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

269 

270 Parameters 

271 ------------ 

272 obj : np.ndarray 

273 Array to check the shape on 

274 shape : list or tuple 

275 Any negative term will be considered a wildcard 

276 Any tuple term will be evaluated as an OR 

277 allow_zeros: bool 

278 if False, zeros do not match negatives in shape 

279 

280 Returns 

281 --------- 

282 shape_ok : bool 

283 True if shape of obj matches query shape 

284 

285 Examples 

286 ------------------------ 

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

288 

289 In [2]: a.shape 

290 Out[2]: (100, 3) 

291 

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

293 Out[3]: True 

294 

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

296 Out[4]: False 

297 

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

299 Out[5]: True 

300 

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

302 Out[6]: True 

303 

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

305 Out[7]: False 

306 """ 

307 

308 # if the obj.shape is different length than 

309 # the goal shape it means they have different number 

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

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

312 return False 

313 

314 # empty lists with any flexible dimensions match 

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

316 return True 

317 

318 # loop through each integer of the two shapes 

319 # multiple values are sequences 

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

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

322 # check if current field has multiple acceptable values 

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

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

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

326 if i in target: 

327 # obj shape is in the accepted values 

328 continue 

329 else: 

330 return False 

331 

332 # check if current field is a wildcard 

333 if int(target) < 0: 

334 if i == 0 and not allow_zeros: 

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

336 # that to match to a wildcard 

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

338 return False 

339 else: 

340 continue 

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

342 # if they are not equal we have an answer 

343 if target != i: 

344 return False 

345 

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

347 # matches the pattern 

348 return True 

349 

350 

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

352 """ 

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

354 add it to a length 1 sequence and return. 

355 

356 Useful for wrapping functions which sometimes return single 

357 objects and other times return lists of objects. 

358 

359 Parameters 

360 ------------- 

361 obj : object 

362 An object to be made a sequence 

363 

364 Returns 

365 -------------- 

366 as_sequence : (n,) sequence 

367 Contains input value 

368 """ 

369 if is_sequence(obj): 

370 return list(obj) 

371 else: 

372 return [obj] 

373 

374 

375def vector_hemisphere( 

376 vectors: ArrayLike, 

377 return_sign: bool = False, 

378): 

379 """ 

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

381 upper hemisphere. 

382 

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

384 will be reversed. 

385 

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

387 negative X value will be reversed. 

388 

389 Parameters 

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

391 vectors : (n, 3) float 

392 Input vectors 

393 return_sign : bool 

394 Return the sign mask or not 

395 

396 Returns 

397 ---------- 

398 oriented: (n, 3) float 

399 Vectors with same magnitude as source 

400 but possibly reversed to ensure all vectors 

401 are in the same hemisphere. 

402 sign : (n,) float 

403 [OPTIONAL] sign of original vectors 

404 """ 

405 # vectors as numpy array 

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

407 

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

409 # 2D vector case 

410 # check the Y value and reverse vector 

411 # direction if negative. 

412 negative = vectors < -TOL_ZERO 

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

414 

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

416 # negative Y values are reversed 

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

418 

419 # zero Y and negative X are reversed 

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

421 

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

423 # 3D vector case 

424 negative = vectors < -TOL_ZERO 

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

426 # move all negative Z to positive 

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

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

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

430 # all vectors with negative Z values 

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

432 # all on-plane vectors with negative Y values 

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

434 # all on-plane vectors with zero Y values 

435 # and negative X values 

436 signs[ 

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

438 ] = -1.0 

439 

440 else: 

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

442 

443 # apply the signs to the vectors 

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

445 

446 if return_sign: 

447 return oriented, signs 

448 

449 return oriented 

450 

451 

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

453 """ 

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

455 vectors. 

456 

457 Parameters 

458 ------------ 

459 cartesian : (n, 3) float 

460 Points in space 

461 

462 Returns 

463 ------------ 

464 spherical : (n, 2) float 

465 Angles, in radians 

466 """ 

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

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

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

470 

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

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

473 

474 x, y, z = unit.T 

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

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

477 return spherical 

478 

479 

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

481 """ 

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

483 

484 Parameters 

485 ------------ 

486 spherical : (n , 2) float 

487 Angles, in radians 

488 

489 Returns 

490 ----------- 

491 vectors : (n, 3) float 

492 Unit vectors 

493 """ 

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

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

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

497 

498 theta, phi = spherical.T 

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

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

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

502 

503 

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

505 """ 

506 For an iterable, group values into pairs. 

507 

508 Parameters 

509 ------------ 

510 iterable : (m, ) list 

511 A sequence of values 

512 

513 Returns 

514 ----------- 

515 pairs: (n, 2) 

516 Pairs of sequential values 

517 

518 Example 

519 ----------- 

520 In [1]: data 

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

522 

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

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

525 

526 """ 

527 # looping through a giant numpy array would be dumb 

528 # so special case ndarrays and use numpy operations 

529 if isinstance(iterable, np.ndarray): 

530 iterable = iterable.reshape(-1) 

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

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

533 return pairs 

534 

535 # if we have a normal iterable use itertools 

536 import itertools 

537 

538 a, b = itertools.tee(iterable) 

539 # pop the first element of the second item 

540 next(b) 

541 

542 return zip(a, b) 

543 

544 

545multi_dot = np.linalg.multi_dot 

546 

547 

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

549 """ 

550 Dot product by row of a and b. 

551 

552 There are a lot of ways to do this though 

553 performance varies very widely. This method 

554 uses a dot product to sum the row and avoids 

555 function calls if at all possible. 

556 

557 Comparing performance of some equivalent versions: 

558 ``` 

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

560 

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

562 

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

564 

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

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

567 

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

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

570 

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

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

573 

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

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

576 

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

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

579 ``` 

580 

581 Parameters 

582 ------------ 

583 a : (m, d) float 

584 First array 

585 b : (m, d) float 

586 Second array 

587 

588 Returns 

589 ------------- 

590 result : (m,) float 

591 Dot product of each row 

592 """ 

593 # make sure `a` is numpy array 

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

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

596 a = np.asanyarray(a) 

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

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

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

600 

601 

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

603 """ 

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

605 

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

607 three times faster due to being less general. 

608 

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

610 76.3 us +/- 651 ns per loop 

611 

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

613 220 us +/- 5.41 us per loop 

614 

615 Parameters 

616 ------------- 

617 data : (n, d) float 

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

619 

620 Returns 

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

622 norm : (n,) float 

623 Norm of each row of input array 

624 """ 

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

626 

627 

628def stack_3D( 

629 points: ArrayLike, 

630 return_2D: bool = False, 

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

632 """ 

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

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

635 

636 Parameters 

637 ------------ 

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

639 Points in either 2D or 3D space 

640 return_2D : bool 

641 Were the original points 2D? 

642 

643 Returns 

644 ---------- 

645 points : (n, 3) float 

646 Points in space 

647 is_2D : bool 

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

649 """ 

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

651 shape = points.shape 

652 

653 if shape == (0,): 

654 is_2D = False 

655 elif len(shape) != 2: 

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

657 elif shape[1] == 2: 

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

659 is_2D = True 

660 elif shape[1] == 3: 

661 is_2D = False 

662 else: 

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

664 

665 if return_2D: 

666 return points, is_2D 

667 

668 return points 

669 

670 

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

672 """ 

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

674 

675 Parameters 

676 ------------ 

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

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

679 

680 Returns 

681 --------- 

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

683 """ 

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

685 if len(bounds) != 2: 

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

687 

688 # allow single float or per-dimension spacing 

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

690 if step.shape == (): 

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

692 

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

694 grid = ( 

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

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

697 .T 

698 ) 

699 return grid 

700 

701 

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

703 """ 

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

705 

706 Parameters 

707 ------------ 

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

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

710 

711 Returns 

712 --------- 

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

714 """ 

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

716 if len(bounds) != 2: 

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

718 

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

720 if count.shape == (): 

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

722 

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

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 multi_dict( 

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

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

735 """ 

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

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

738 

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

740 

741 Parameters 

742 ------------ 

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

744 

745 Returns 

746 ---------- 

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

748 

749 """ 

750 result = collections.defaultdict(list) 

751 for k, v in pairs: 

752 result[k].append(v) 

753 return result 

754 

755 

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

757 """ 

758 Ensure that any arrays or dicts passed containing 

759 numpy arrays are properly converted to lists 

760 

761 Parameters 

762 ------------- 

763 data : any 

764 Usually a dict with some numpy arrays as values 

765 

766 Returns 

767 ---------- 

768 result : any 

769 JSON-serializable version of data 

770 """ 

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

772 return result 

773 

774 

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

776 """ 

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

778 """ 

779 start = file_obj.tell() 

780 fbytes = file_obj.read(1024) 

781 file_obj.seek(start) 

782 is_str = isinstance(fbytes, str) 

783 for fbyte in fbytes: 

784 if is_str: 

785 code = ord(fbyte) 

786 else: 

787 code = fbyte 

788 if code > 127: 

789 return True 

790 return False 

791 

792 

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

794 """ 

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

796 

797 Parameters 

798 ------------ 

799 file_obj: open file-like object 

800 

801 Returns 

802 ---------- 

803 distance: int, bytes to end of file 

804 """ 

805 position_current = file_obj.tell() 

806 file_obj.seek(0, 2) 

807 position_end = file_obj.tell() 

808 file_obj.seek(position_current) 

809 distance = position_end - position_current 

810 return distance 

811 

812 

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

814 """ 

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

816 

817 Parameters 

818 ----------- 

819 decimal: float 

820 min_digits: int, minimum number of digits to return 

821 

822 Returns 

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

824 

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

826 """ 

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

828 if min_digits is not None: 

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

830 return int(digits) 

831 

832 

833def attach_to_log( 

834 level: Integer = logging.DEBUG, 

835 handler: logging.Handler | None = None, 

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

837 colors: bool = True, 

838 capture_warnings: bool = True, 

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

840 only_parent: bool = True, 

841) -> None: 

842 """ 

843 Attach a stream handler to all loggers. 

844 

845 Parameters 

846 ------------ 

847 level : enum 

848 Logging level, like logging.INFO 

849 handler : None or logging.Handler 

850 Handler to attach 

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

852 If None, will try to attach to all available 

853 colors : bool 

854 If True try to use colorlog formatter 

855 blacklist : (n,) str 

856 Names of loggers NOT to attach to 

857 only_parent 

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

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

860 """ 

861 

862 # default blacklist includes ipython debugging stuff 

863 if blacklist is None: 

864 blacklist = [ 

865 "TerminalIPythonApp", 

866 "PYREADLINE", 

867 "pyembree", 

868 "shapely", 

869 "matplotlib", 

870 "parso.cache", 

871 "parso", 

872 "parso.python.diff", 

873 "asyncio", 

874 "prompt_toolkit.buffer", 

875 ] 

876 

877 # make sure we log warnings from the warnings module 

878 logging.captureWarnings(capture_warnings) 

879 

880 # create a basic formatter 

881 formatter = logging.Formatter( 

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

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

884 ) 

885 if colors: 

886 try: 

887 from colorlog import ColoredFormatter 

888 

889 formatter = ColoredFormatter( 

890 ( 

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

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

893 ), 

894 datefmt=None, 

895 reset=True, 

896 log_colors={ 

897 "DEBUG": "cyan", 

898 "INFO": "green", 

899 "WARNING": "yellow", 

900 "ERROR": "red", 

901 "CRITICAL": "red", 

902 }, 

903 ) 

904 except ImportError: 

905 pass 

906 

907 # if no handler was passed use a StreamHandler 

908 if handler is None: 

909 handler = logging.StreamHandler() 

910 

911 # add the formatters and set the level 

912 handler.setFormatter(formatter) 

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

914 handler.setLevel(int(level)) 

915 

916 # if nothing passed use all available loggers 

917 if loggers is None: 

918 # de-duplicate loggers using a set 

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

920 

921 # add the warnings logging 

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

923 

924 # disable pyembree warnings 

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

926 

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

928 loggers_dict = { 

929 L.name: L 

930 for L in loggers 

931 if hasattr(L, "name") 

932 and isinstance(L, logging.Logger) 

933 and L.name not in blacklist 

934 } 

935 

936 if only_parent: 

937 # create a new dict to store only parent loggers 

938 parent_loggers = {} 

939 # sort logger names to process in hierarchical order 

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

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

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

943 parent_loggers[name] = loggers_dict[name] 

944 # replace loggers dict with only parent loggers 

945 loggers_dict = parent_loggers 

946 

947 # loop through all available loggers 

948 for logger in loggers_dict.values(): 

949 logger.addHandler(handler) 

950 logger.setLevel(int(level)) 

951 

952 # set nicer numpy print options 

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

954 

955 

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

957 """ 

958 Stack a list of values that represent a polyline into 

959 individual line segments with duplicated consecutive values. 

960 

961 Parameters 

962 ------------ 

963 indices : (m,) any 

964 List of items to be stacked 

965 

966 Returns 

967 --------- 

968 stacked : (n, 2) any 

969 Stacked items 

970 

971 Examples 

972 ---------- 

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

974 Out[1]: 

975 array([[0, 1], 

976 [1, 2]]) 

977 

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

979 Out[2]: 

980 array([[0, 1], 

981 [1, 2], 

982 [2, 4], 

983 [4, 5]]) 

984 

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

986 Out[3]: 

987 array([[0, 0], 

988 [1, 1], 

989 [1, 1], 

990 [2, 2], 

991 [2, 2], 

992 [3, 3]]) 

993 

994 """ 

995 indices = np.asanyarray(indices) 

996 if len(indices) == 0: 

997 return np.array([]) 

998 elif is_sequence(indices[0]): 

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

1000 else: 

1001 shape = (-1, 2) 

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

1003 

1004 

1005def append_faces( 

1006 vertices_seq: Iterable[ArrayLike], 

1007 faces_seq: Iterable[ArrayLike], 

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

1009 """ 

1010 Given a sequence of zero-indexed faces and vertices 

1011 combine them into a single array of faces and 

1012 a single array of vertices. 

1013 

1014 Parameters 

1015 ----------- 

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

1017 Multiple arrays of verticesvertex arrays 

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

1019 Zero indexed faces for matching vertices 

1020 

1021 Returns 

1022 ---------- 

1023 vertices : (i, d) float 

1024 Points in space 

1025 faces : (j, 3) int 

1026 Reference vertex indices 

1027 """ 

1028 # the length of each vertex array 

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

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

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

1032 

1033 new_faces = [] 

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

1035 if len(faces) == 0: 

1036 continue 

1037 # apply the index offset 

1038 new_faces.append(faces + offset) 

1039 # stack to clean (n, 3) float 

1040 vertices = vstack_empty(vertices_seq) 

1041 # stack to clean (n, 3) int 

1042 faces = vstack_empty(new_faces) 

1043 

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

1045 # documented 2d shape and integer face dtype 

1046 if len(vertices) == 0: 

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

1048 if len(faces) == 0: 

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

1050 

1051 return vertices, faces 

1052 

1053 

1054def array_to_string( 

1055 array: ArrayLike, 

1056 col_delim: str = " ", 

1057 row_delim: str = "\n", 

1058 digits: Integer = 8, 

1059 value_format: str = "{}", 

1060) -> str: 

1061 """ 

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

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

1064 basic numpy array to string conversions are surprisingly slow. 

1065 

1066 Parameters 

1067 ------------ 

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

1069 Data to be converted 

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

1071 col_delim : str 

1072 What string should separate values in a column 

1073 row_delim : str 

1074 What string should separate values in a row 

1075 digits : int 

1076 How many digits should floating point numbers include 

1077 value_format : str 

1078 Format string for each value or sequence of values 

1079 If multiple values per value_format it must divide 

1080 into array evenly. 

1081 

1082 Returns 

1083 ---------- 

1084 formatted : str 

1085 String representation of original array 

1086 """ 

1087 # convert inputs to correct types 

1088 array = np.asanyarray(array) 

1089 digits = int(digits) 

1090 row_delim = str(row_delim) 

1091 col_delim = str(col_delim) 

1092 value_format = str(value_format) 

1093 

1094 # abort for non-flat arrays 

1095 if len(array.shape) > 2: 

1096 raise ValueError( 

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

1098 ) 

1099 

1100 # abort for structured arrays 

1101 if array.dtype.names is not None: 

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

1103 

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

1105 repeats = value_format.count("{") 

1106 

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

1108 # integer types don't need a specified precision 

1109 format_str = value_format + col_delim 

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

1111 # add the digits formatting to floats 

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

1113 else: 

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

1115 

1116 # length of extra delimiters at the end 

1117 end_junk = len(col_delim) 

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

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

1120 format_str *= array.shape[1] 

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

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

1123 end_junk = len(row_delim) 

1124 

1125 # expand format string to whole array 

1126 format_str *= len(array) 

1127 

1128 # if an array is repeated in the value format 

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

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

1131 

1132 # run the format operation and remove the extra delimiters 

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

1134 

1135 return formatted 

1136 

1137 

1138def structured_array_to_string( 

1139 array: ArrayLike, 

1140 col_delim: str = " ", 

1141 row_delim: str = "\n", 

1142 digits: Integer = 8, 

1143 value_format: str = "{}", 

1144) -> str: 

1145 """ 

1146 Convert an unstructured array into a string with a specified 

1147 number of digits and delimiter. The reason thisexists is 

1148 that the basic numpy array to string conversions are 

1149 surprisingly slow. 

1150 

1151 Parameters 

1152 ------------ 

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

1154 Data to be converted 

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

1156 col_delim : str 

1157 What string should separate values in a column 

1158 row_delim : str 

1159 What string should separate values in a row 

1160 digits : int 

1161 How many digits should floating point numbers include 

1162 value_format : str 

1163 Format string for each value or sequence of values 

1164 If multiple values per value_format it must divide 

1165 into array evenly. 

1166 

1167 Returns 

1168 ---------- 

1169 formatted : str 

1170 String representation of original array 

1171 """ 

1172 # convert inputs to correct types 

1173 array = np.asanyarray(array) 

1174 digits = int(digits) 

1175 row_delim = str(row_delim) 

1176 col_delim = str(col_delim) 

1177 value_format = str(value_format) 

1178 

1179 # abort for non-flat arrays 

1180 if len(array.shape) > 1: 

1181 raise ValueError( 

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

1183 ) 

1184 

1185 # abort for unstructured arrays 

1186 if array.dtype.names is None: 

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

1188 

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

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

1191 raise ValueError( 

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

1193 + "values is unsupported", 

1194 value_format, 

1195 ) 

1196 

1197 format_str = "" 

1198 for name in array.dtype.names: 

1199 kind = array[name].dtype.kind 

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

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

1202 # integer types need a no-decimal formatting 

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

1204 elif kind == "f": 

1205 # add the digits formatting to floats 

1206 element_format_str = ( 

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

1208 ) 

1209 else: 

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

1211 format_str += element_row_length * element_format_str 

1212 

1213 # length of extra delimiters at the end 

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

1215 # expand format string to whole array 

1216 format_str *= len(array) 

1217 

1218 # loop through flat fields and flatten to single array 

1219 count = len(array) 

1220 # will upgrade everything to a float 

1221 flattened = np.hstack( 

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

1223 ).reshape(-1) 

1224 

1225 # run the format operation and remove the extra delimiters 

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

1227 

1228 return formatted 

1229 

1230 

1231def array_to_encoded( 

1232 array: ArrayLike, 

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

1234 encoding: str = "base64", 

1235) -> Mapping[str, Any]: 

1236 """ 

1237 Export a numpy array to a compact serializable dictionary. 

1238 

1239 Parameters 

1240 ------------ 

1241 array : array 

1242 Any numpy array 

1243 dtype : str or None 

1244 Optional dtype to encode array 

1245 encoding : str 

1246 'base64' or 'binary' 

1247 

1248 Returns 

1249 --------- 

1250 encoded : dict 

1251 Has keys: 

1252 'dtype': str, of dtype 

1253 'shape': tuple of shape 

1254 'base64': str, base64 encoded string 

1255 """ 

1256 array = np.asanyarray(array) 

1257 shape = array.shape 

1258 # ravel also forces contiguous 

1259 flat = np.ravel(array) 

1260 if dtype is None: 

1261 dtype = array.dtype 

1262 

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

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

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

1266 if hasattr(packed, "decode"): 

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

1268 encoded["base64"] = packed 

1269 elif encoding == "binary": 

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

1271 else: 

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

1273 return encoded 

1274 

1275 

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

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

1278 """ 

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

1280 

1281 Parameters 

1282 ------------ 

1283 store : dict 

1284 Dictionary with data 

1285 

1286 Returns 

1287 --------- 

1288 result : dict 

1289 Values are untouched but keys that were bytes 

1290 are converted to ASCII strings. 

1291 

1292 Example 

1293 ----------- 

1294 In [1]: d 

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

1296 

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

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

1299 """ 

1300 keys = store.keys() 

1301 for key in keys: 

1302 if hasattr(key, "decode"): 

1303 decoded = key.decode(encoding) 

1304 if key != decoded: 

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

1306 store.pop(key) 

1307 return store 

1308 

1309 

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

1311 """ 

1312 Strip comments from a text block. 

1313 

1314 Parameters 

1315 ----------- 

1316 text : str 

1317 Text to remove comments from 

1318 starts_with : str 

1319 Character or substring that starts a comment 

1320 new_line : str 

1321 Character or substring that ends a comment 

1322 

1323 Returns 

1324 ----------- 

1325 stripped : str 

1326 Text with comments stripped 

1327 """ 

1328 # if not contained exit immediately 

1329 if starts_with not in text: 

1330 return text 

1331 

1332 # start by splitting into chunks by the comment indicator 

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

1334 

1335 # special case files that start with a comment 

1336 if text.startswith(starts_with): 

1337 lead = "" 

1338 else: 

1339 lead = split[0] 

1340 

1341 # take each comment up until the newline 

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

1343 # add the leading string back on 

1344 result = ( 

1345 lead 

1346 + new_line 

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

1348 ) 

1349 # strip leading and trailing whitespace 

1350 result = result.strip() 

1351 

1352 return result 

1353 

1354 

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

1356 """ 

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

1358 

1359 Parameters 

1360 ------------ 

1361 encoded 

1362 Has keys: 

1363 dtype: string of dtype 

1364 shape: int tuple of shape 

1365 base64: base64 encoded string of flat array 

1366 binary: decode result coming from numpy.tobytes 

1367 

1368 Returns 

1369 ---------- 

1370 array 

1371 """ 

1372 

1373 if not isinstance(encoded, dict): 

1374 if is_sequence(encoded): 

1375 as_array = np.asanyarray(encoded) 

1376 return as_array 

1377 else: 

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

1379 

1380 encoded_dict = decode_keys(encoded) 

1381 

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

1383 if "base64" in encoded_dict: 

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

1385 elif "binary" in encoded_dict: 

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

1387 else: 

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

1389 if "shape" in encoded_dict: 

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

1391 return array 

1392 

1393 

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

1395 """ 

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

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

1398 

1399 Parameters 

1400 ------------ 

1401 obj : instance 

1402 Some object of some class 

1403 name: str 

1404 The name of the class we want to check for 

1405 

1406 Returns 

1407 --------- 

1408 is_instance : bool 

1409 Whether the object is a member of the named class 

1410 """ 

1411 try: 

1412 if isinstance(name, list): 

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

1414 else: 

1415 type_named(obj, name) 

1416 return True 

1417 except ValueError: 

1418 return False 

1419 

1420 

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

1422 """ 

1423 Return the bases of the object passed. 

1424 """ 

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

1426 for i in range(depth): 

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

1428 try: 

1429 bases_flat = np.hstack(bases) 

1430 except IndexError: 

1431 return [] 

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

1433 

1434 

1435def type_named(obj: object, name: str) -> type | None: 

1436 """ 

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

1438 for named instance. 

1439 

1440 Parameters 

1441 ------------ 

1442 obj : any 

1443 Object to look for class of 

1444 name : str 

1445 Nnme of class 

1446 

1447 Returns 

1448 ---------- 

1449 class : Callable | None 

1450 Named class, or None 

1451 """ 

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

1453 name = str(name) 

1454 if obj.__class__.__name__ == name: 

1455 return obj.__class__ 

1456 for base in type_bases(obj): 

1457 if base.__name__ == name: 

1458 return base 

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

1460 

1461 

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

1463 """ 

1464 Concatenate two or more meshes. 

1465 

1466 Parameters 

1467 ------------ 

1468 a : trimesh.Trimesh 

1469 Mesh or list of meshes to be concatenated 

1470 object, or list of such 

1471 b : trimesh.Trimesh 

1472 Mesh or list of meshes to be concatenated 

1473 

1474 Returns 

1475 ---------- 

1476 result 

1477 Concatenated mesh 

1478 """ 

1479 dump = [] 

1480 for i in chain(a, b): 

1481 if is_instance_named(i, "Scene"): 

1482 # get every mesh in the final frame. 

1483 dump.extend(i.dump()) 

1484 else: 

1485 # just append to our flat list 

1486 dump.append(i) 

1487 

1488 if len(dump) == 1: 

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

1490 return dump[0].copy() 

1491 elif len(dump) == 0: 

1492 # if there are no meshes return an empty mesh 

1493 from .base import Trimesh 

1494 

1495 return Trimesh() 

1496 

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

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

1499 

1500 # if we have more 

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

1502 from .path.util import concatenate as concatenate_path 

1503 

1504 return concatenate_path(is_path) 

1505 

1506 if len(is_mesh) == 0: 

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

1508 # branch above and hand back an empty mesh 

1509 from .base import Trimesh 

1510 

1511 return Trimesh() 

1512 

1513 # extract the trimesh type to avoid a circular import 

1514 # and assert that all inputs are Trimesh objects 

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

1516 

1517 # append faces and vertices of meshes 

1518 vertices, faces = append_faces( 

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

1520 ) 

1521 

1522 # save face normals if already calculated 

1523 face_normals = None 

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

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

1526 assert face_normals.shape == faces.shape 

1527 

1528 # save vertex normals if any mesh has them 

1529 vertex_normals = None 

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

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

1532 assert vertex_normals.shape == vertices.shape 

1533 

1534 try: 

1535 # concatenate visuals 

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

1537 except BaseException as E: 

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

1539 visual = None 

1540 if _STRICT: 

1541 raise E 

1542 

1543 metadata = {} 

1544 try: 

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

1546 except BaseException: 

1547 pass 

1548 

1549 # concatenate vertex attributes that are valid for every mesh 

1550 vertex_attributes = {} 

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

1552 # make sure every mesh has a valid attribute 

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

1554 try: 

1555 vertex_attributes[key] = np.concatenate( 

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

1557 ) 

1558 except BaseException: 

1559 log.warning( 

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

1561 ) 

1562 

1563 # concatenate face attributes that are valid for every mesh 

1564 face_attributes = {} 

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

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

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

1568 try: 

1569 # stack along axis 0 

1570 face_attributes[key] = np.concatenate( 

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

1572 ) 

1573 except BaseException: 

1574 # could have failed because attribute had different shapes 

1575 log.warning( 

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

1577 ) 

1578 

1579 # create the mesh object 

1580 assert trimesh_type is not None 

1581 result = trimesh_type( 

1582 vertices=vertices, 

1583 faces=faces, 

1584 face_normals=face_normals, 

1585 vertex_normals=vertex_normals, 

1586 visual=visual, 

1587 vertex_attributes=vertex_attributes, 

1588 face_attributes=face_attributes, 

1589 metadata=metadata, 

1590 process=False, 

1591 ) 

1592 

1593 try: 

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

1595 except BaseException: 

1596 pass 

1597 

1598 return result 

1599 

1600 

1601def submesh( 

1602 mesh, 

1603 faces_sequence: Iterable[ArrayLike], 

1604 repair: bool = True, 

1605 only_watertight: bool = False, 

1606 min_faces: Integer | None = None, 

1607 append: bool = False, 

1608): 

1609 """ 

1610 Return a subset of a mesh. 

1611 

1612 Parameters 

1613 ------------ 

1614 mesh : Trimesh 

1615 Source mesh to take geometry from 

1616 faces_sequence : sequence (p,) int 

1617 Indexes of mesh.faces 

1618 repair 

1619 Try to make submeshes watertight 

1620 only_watertight 

1621 Only return submeshes which are watertight 

1622 min_faces 

1623 Minimum number of faces allowed in a submesh. 

1624 append : bool 

1625 Return a single mesh which has the faces appended, 

1626 if this flag is set, only_watertight is ignored 

1627 

1628 Returns 

1629 --------- 

1630 result : Trimesh | list[Trimesh] 

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

1632 """ 

1633 # evaluate generators so we can escape early 

1634 faces_sequence = list(faces_sequence) 

1635 

1636 if len(faces_sequence) == 0: 

1637 return [] 

1638 

1639 # avoid nuking the cache on the original mesh 

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

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

1642 

1643 faces = [] 

1644 vertices = [] 

1645 normals = [] 

1646 visuals = [] 

1647 

1648 # for reindexing faces 

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

1650 

1651 for index in faces_sequence: 

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

1653 index = np.asanyarray(index) 

1654 if len(index) == 0: 

1655 # regardless of type empty arrays are useless 

1656 continue 

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

1658 # if passed a bool with no true continue 

1659 if not index.any(): 

1660 continue 

1661 # if fewer faces than minimum 

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

1663 continue 

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

1665 continue 

1666 

1667 current = original_faces[index] 

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

1669 

1670 # redefine face indices from zero 

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

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

1673 faces.append(mask[current]) 

1674 vertices.append(original_vertices[unique]) 

1675 

1676 assert mesh.visual is not None 

1677 try: 

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

1679 except BaseException as E: 

1680 raise E 

1681 visuals = None 

1682 

1683 if len(vertices) == 0: 

1684 return [] 

1685 

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

1687 # to avoid a circular import 

1688 trimesh_type = type_named(mesh, "Trimesh") 

1689 assert trimesh_type is not None 

1690 

1691 if append: 

1692 visual = None 

1693 try: 

1694 visuals = np.array(visuals) 

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

1696 except Exception: 

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

1698 # re-index faces and stack 

1699 vertices, faces = append_faces(vertices, faces) 

1700 appended = trimesh_type( 

1701 vertices=vertices, 

1702 faces=faces, 

1703 face_normals=np.vstack(normals), 

1704 visual=visual, 

1705 metadata=deepcopy(mesh.metadata), 

1706 process=False, 

1707 ) 

1708 appended._source = deepcopy(mesh.source) 

1709 

1710 return appended 

1711 

1712 if visuals is None: 

1713 visuals = [None] * len(vertices) 

1714 

1715 # generate a list of Trimesh objects 

1716 result = [ 

1717 trimesh_type( 

1718 vertices=v, 

1719 faces=f, 

1720 face_normals=n, 

1721 visual=c, 

1722 metadata=deepcopy(mesh.metadata), 

1723 process=False, 

1724 ) 

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

1726 ] 

1727 

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

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

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

1731 

1732 if repair: 

1733 # fill_holes will attempt a repair and returns the 

1734 # watertight status at the end of the repair attempt 

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

1736 elif only_watertight: 

1737 # calculate watertightness without repairing 

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

1739 

1740 if only_watertight: 

1741 # return only the watertight meshes 

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

1743 

1744 return result 

1745 

1746 

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

1748 """ 

1749 Parameters 

1750 ------------ 

1751 data : (n,) 

1752 1D array 

1753 count : int 

1754 Minimum length of result array 

1755 

1756 Returns 

1757 --------- 

1758 padded : (m,) 

1759 1D array where m >= count 

1760 """ 

1761 if len(data) == 0: 

1762 return np.zeros(count) 

1763 elif len(data) < count: 

1764 padded = np.zeros(count) 

1765 if right: 

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

1767 else: 

1768 padded[: len(data)] = data 

1769 return padded 

1770 else: 

1771 return np.asanyarray(data) 

1772 

1773 

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

1775 """ 

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

1777 by creating a custom encoder for numpy dtypes. 

1778 

1779 Parameters 

1780 -------------- 

1781 obj : list, dict 

1782 A JSON-serializable blob 

1783 kwargs : dict 

1784 Passed to json.dumps 

1785 

1786 Returns 

1787 -------------- 

1788 dumped : str 

1789 JSON dump of obj 

1790 """ 

1791 

1792 class EdgeEncoder(json.JSONEncoder): 

1793 def default(self, o: Any) -> str: 

1794 # will work for numpy.ndarrays 

1795 # as well as their int64/etc objects 

1796 if hasattr(o, "tolist"): 

1797 return o.tolist() 

1798 elif hasattr(o, "timestamp"): 

1799 return o.timestamp() 

1800 return json.JSONEncoder.default(self, o) 

1801 

1802 # run the dumps using our encoder 

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

1804 

1805 

1806def convert_like(item, like): 

1807 """ 

1808 Convert an item to have the dtype of another item 

1809 

1810 Parameters 

1811 ------------ 

1812 item : any 

1813 Item to be converted 

1814 like : any 

1815 Object with target dtype 

1816 If None, item is returned unmodified 

1817 

1818 Returns 

1819 ---------- 

1820 result: item, but in dtype of like 

1821 """ 

1822 # if it's a numpy array 

1823 if isinstance(like, np.ndarray): 

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

1825 

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

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

1828 return item 

1829 

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

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

1832 return item[0] 

1833 

1834 if ( 

1835 isinstance(item, str) 

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

1837 and item.startswith("POLYGON") 

1838 ): 

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

1840 # the import was a WKT serialized polygon 

1841 from shapely import wkt 

1842 

1843 return wkt.loads(item) 

1844 

1845 # otherwise just run the conversion 

1846 item = like.__class__(item) 

1847 

1848 return item 

1849 

1850 

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

1852 """ 

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

1854 broad-phase collision detection. 

1855 

1856 Parameters 

1857 ------------ 

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

1859 Non-interleaved bounds where D=dimension 

1860 E.G a 2D bounds tree: 

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

1862 

1863 Returns 

1864 --------- 

1865 tree : Rtree 

1866 Tree containing bounds by index 

1867 """ 

1868 import rtree 

1869 

1870 # make sure we've copied bounds 

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

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

1873 # should be min-max per bound 

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

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

1876 # reshape to one-row-per-hyperrectangle 

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

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

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

1880 

1881 # check to make sure we have correct shape 

1882 dimension = bounds.shape[1] 

1883 if (dimension % 2) != 0: 

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

1885 dimension = int(dimension / 2) 

1886 

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

1888 # stream load was verified working on import above 

1889 return rtree.index.Index( 

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

1891 ) 

1892 

1893 

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

1895 """ 

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

1897 

1898 Parameters 

1899 ------------ 

1900 item: str or bytes 

1901 Item to be wrapped 

1902 

1903 Returns 

1904 --------- 

1905 wrapped : file-like object 

1906 Contains data from item 

1907 """ 

1908 if isinstance(item, str): 

1909 return StringIO(item) 

1910 elif isinstance(item, bytes): 

1911 return BytesIO(item) 

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

1913 

1914 

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

1916 """ 

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

1918 

1919 Parameters 

1920 ------------ 

1921 values : float 

1922 Value to be rounded 

1923 sigfig : int 

1924 Number of significant figures to reduce to 

1925 

1926 Returns 

1927 ---------- 

1928 rounded : float 

1929 Value rounded to the specified number of significant figures 

1930 

1931 

1932 Examples 

1933 ---------- 

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

1935 Out[1]: -200000.0 

1936 

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

1938 Out[2]: 0.0001 

1939 

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

1941 Out[3]: 0.0001405 

1942 """ 

1943 as_int, multiplier = sigfig_int(values, sigfig) 

1944 rounded = as_int * (10**multiplier) 

1945 

1946 return rounded 

1947 

1948 

1949def sigfig_int( 

1950 values: ArrayLike, sigfig: ArrayLike 

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

1952 """ 

1953 Convert a set of floating point values into integers 

1954 with a specified number of significant figures and an 

1955 exponent. 

1956 

1957 Parameters 

1958 ------------ 

1959 values : (n,) float or int 

1960 Array of values 

1961 sigfig : (n,) int 

1962 Number of significant figures to keep 

1963 

1964 Returns 

1965 ------------ 

1966 as_int : (n,) int 

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

1968 multiplier : (n,) float 

1969 Exponent, so as_int * 10 ** multiplier is 

1970 the same order of magnitude as the input 

1971 """ 

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

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

1974 

1975 if sigfig.shape != values.shape: 

1976 raise ValueError("sigfig must match identifier") 

1977 

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

1979 nonzero = np.abs(values) > TOL_ZERO 

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

1981 

1982 multiplier = exponent - sigfig + 1 

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

1984 

1985 return as_int, multiplier 

1986 

1987 

1988# cap on total uncompressed bytes from a single archive 

1989MAX_ARCHIVE_SIZE = 8 * 1024**3 # 8 GiB 

1990 

1991 

1992def decompress( 

1993 file_obj: bytes | Stream, 

1994 file_type: str, 

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

1996 """ 

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

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

1999 

2000 Total uncompressed size is capped at `MAX_ARCHIVE_SIZE`; reads stop 

2001 one byte past the budget rather than trusting declared member sizes. 

2002 

2003 Parameters 

2004 ------------ 

2005 file_obj : file-like 

2006 Containing compressed data. 

2007 file_type : str 

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

2009 

2010 Returns 

2011 --------- 

2012 decompressed : dict 

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

2014 """ 

2015 file_type = str(file_type).lower() 

2016 if isinstance(file_obj, bytes): 

2017 file_obj = BytesIO(file_obj) 

2018 

2019 if file_type.endswith("zip"): 

2020 archive = zipfile.ZipFile(file_obj) 

2021 result = {} 

2022 total = 0 

2023 for info in archive.infolist(): 

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

2025 # read one past the remaining budget to detect overflow 

2026 data = src.read(MAX_ARCHIVE_SIZE - total + 1) 

2027 if total + len(data) > MAX_ARCHIVE_SIZE: 

2028 raise ValueError("archive exceeds size cap") 

2029 total += len(data) 

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

2031 return result 

2032 if file_type.endswith("bz2"): 

2033 import bz2 

2034 

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

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

2037 data = bz2.open(file_obj, mode="r").read(MAX_ARCHIVE_SIZE + 1) 

2038 if len(data) > MAX_ARCHIVE_SIZE: 

2039 raise ValueError("archive exceeds size cap") 

2040 return {name: wrap_as_stream(data)} 

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

2042 import tarfile 

2043 

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

2045 result = {} 

2046 total = 0 

2047 for info in archive.getmembers(): 

2048 if not info.isfile(): 

2049 continue 

2050 src = archive.extractfile(info) 

2051 if src is None: 

2052 continue 

2053 # read one past the remaining budget rather than trusting info.size 

2054 data = src.read(MAX_ARCHIVE_SIZE - total + 1) 

2055 if total + len(data) > MAX_ARCHIVE_SIZE: 

2056 raise ValueError("archive exceeds size cap") 

2057 total += len(data) 

2058 result[info.name] = wrap_as_stream(data) 

2059 return result 

2060 raise ValueError("Unsupported type passed!") 

2061 

2062 

2063def compress( 

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

2065 **kwargs: Any, 

2066) -> bytes: 

2067 """ 

2068 Compress data stored in a dict. 

2069 

2070 Parameters 

2071 ----------- 

2072 info : dict 

2073 Data to compress in form: 

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

2075 kwargs : dict 

2076 Passed to zipfile.ZipFile 

2077 Returns 

2078 ----------- 

2079 compressed : bytes 

2080 Compressed file data 

2081 """ 

2082 file_obj = BytesIO() 

2083 with zipfile.ZipFile( 

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

2085 ) as zipper: 

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

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

2088 data = data_or_file 

2089 else: 

2090 # a file-like object — read its contents 

2091 data = data_or_file.read() 

2092 zipper.writestr(name, data) 

2093 file_obj.seek(0) 

2094 compressed = file_obj.read() 

2095 return compressed 

2096 

2097 

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

2099 """ 

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

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

2102 

2103 Parameters 

2104 ------------ 

2105 file_name : str 

2106 File name 

2107 special : list of str 

2108 Multipart extensions 

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

2110 

2111 Returns 

2112 ---------- 

2113 extension : str 

2114 Last characters after a period, or 

2115 a value from 'special' 

2116 """ 

2117 file_name = str(file_name) 

2118 

2119 if special is None: 

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

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

2122 for end in special: 

2123 if file_name.endswith(end): 

2124 return end 

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

2126 

2127 

2128def triangle_strips_to_faces( 

2129 strips: ArrayLike | Sequence[ArrayLike], 

2130) -> NDArray2D[np.int64]: 

2131 """ 

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

2133 

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

2135 faster than loop-based methods. 

2136 

2137 From the OpenGL programming guide describing a single triangle 

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

2139 

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

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

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

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

2144 part of a surface. 

2145 

2146 Parameters 

2147 ------------ 

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

2149 Vertex indices 

2150 

2151 Returns 

2152 ------------ 

2153 faces : (m, 3) int 

2154 Vertex indices representing triangles 

2155 """ 

2156 

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

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

2159 # looping through a list of lists is extremely slow 

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

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

2162 

2163 # slice the blob into rough triangles 

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

2165 

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

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

2168 if len(strips) == 1: 

2169 # flip in-place every other triangle 

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

2171 return tri 

2172 

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

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

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

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

2177 keep[length_index - 2] = False 

2178 keep[length_index - 1] = False 

2179 tri = tri[keep] 

2180 

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

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

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

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

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

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

2187 

2188 return tri 

2189 

2190 

2191def triangle_fans_to_faces( 

2192 fans: Iterable[ArrayLike], 

2193) -> NDArray2D[np.int64]: 

2194 """ 

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

2196 

2197 Parameters 

2198 ---------- 

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

2200 Vertex indices 

2201 

2202 Returns 

2203 ------- 

2204 faces: (m, 3) int 

2205 Vertex indices representing triangles 

2206 """ 

2207 

2208 faces = [ 

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

2210 for fan in fans 

2211 ] 

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

2213 

2214 

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

2216 """ 

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

2218 

2219 Parameters 

2220 ------------ 

2221 tup : tuple or list of arrays 

2222 With the same number of columns 

2223 

2224 Returns 

2225 ------------ 

2226 stacked : (n, d) array 

2227 With same number of columns as 

2228 constituent arrays. 

2229 """ 

2230 # filter out empty arrays 

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

2232 # if we only have one array just return it 

2233 if len(stackable) == 1: 

2234 return np.asanyarray(stackable[0]) 

2235 # if we have nothing return an empty numpy array 

2236 elif len(stackable) == 0: 

2237 return np.array([]) 

2238 # otherwise just use vstack as normal 

2239 return np.vstack(stackable) 

2240 

2241 

2242def write_encoded( 

2243 file_obj: Stream, 

2244 stuff: str | bytes, 

2245 encoding: str = "utf-8", 

2246) -> str | bytes: 

2247 """ 

2248 If a file is open in binary mode and a 

2249 string is passed, encode and write. 

2250 

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

2252 passed decode bytes to str and write. 

2253 

2254 Assumes binary mode if file_obj does not have 

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

2256 

2257 Parameters 

2258 ----------- 

2259 file_obj : file object 

2260 With 'write' and 'mode' 

2261 stuff : str or bytes 

2262 Stuff to be written 

2263 encoding : str 

2264 Encoding of text 

2265 """ 

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

2267 string_stuff = isinstance(stuff, str) 

2268 binary_stuff = isinstance(stuff, bytes) 

2269 

2270 if binary_file and string_stuff: 

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

2272 elif not binary_file and binary_stuff: 

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

2274 else: 

2275 file_obj.write(stuff) 

2276 file_obj.flush() 

2277 return stuff 

2278 

2279 

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

2281 """ 

2282 Generate a random alphaNumber unique identifier 

2283 using UUID logic. 

2284 

2285 Parameters 

2286 ------------ 

2287 length : int 

2288 Length of desired identifier 

2289 

2290 Returns 

2291 ------------ 

2292 unique : str 

2293 Unique alphaNumber identifier 

2294 """ 

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

2296 

2297 

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

2299 """ 

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

2301 from a given z-axis vector. 

2302 

2303 Parameters 

2304 ------------ 

2305 z : (3,) float 

2306 A vector along the positive z-axis. 

2307 epsilon : float 

2308 Numbers smaller than this considered zero. 

2309 

2310 Returns 

2311 --------- 

2312 x : (3,) float 

2313 Vector along x axis. 

2314 y : (3,) float 

2315 Vector along y axis. 

2316 z : (3,) float 

2317 Vector along z axis. 

2318 """ 

2319 # get a copy of input vector 

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

2321 # must be a 3D vector 

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

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

2324 

2325 z_norm = np.linalg.norm(z) 

2326 if z_norm < epsilon: 

2327 return np.eye(3) 

2328 

2329 # normalize vector in-place 

2330 z /= z_norm 

2331 # X as arbitrary perpendicular vector 

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

2333 # avoid degenerate case 

2334 x_norm = np.linalg.norm(x) 

2335 if x_norm < epsilon: 

2336 # this means that 

2337 # so a perpendicular X is just X 

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

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

2340 else: 

2341 # otherwise normalize X in-place 

2342 x /= x_norm 

2343 # get perpendicular Y with cross product 

2344 y = np.cross(z, x) 

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

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

2347 

2348 if _STRICT: 

2349 # run checks to make sure axis are perpendicular 

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

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

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

2353 # all vectors should be unit vector 

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

2355 

2356 return result 

2357 

2358 

2359def isclose( 

2360 a: Number | NDArray, 

2361 b: Number | NDArray, 

2362 atol: Floating = 1e-8, 

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

2364 """ 

2365 A replacement for np.isclose that does fewer checks 

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

2367 

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

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

2370 

2371 Parameters 

2372 ------------ 

2373 a : np.ndarray 

2374 To be compared 

2375 b : np.ndarray 

2376 To be compared 

2377 atol : float 

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

2379 

2380 Returns 

2381 ----------- 

2382 close : np.ndarray, bool 

2383 Per-element closeness 

2384 """ 

2385 diff = a - b 

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

2387 

2388 

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

2390 """ 

2391 A replacement for np.allclose that does few checks 

2392 and validation and as a result is faster. 

2393 

2394 Parameters 

2395 ------------ 

2396 a : np.ndarray 

2397 To be compared 

2398 b : np.ndarray 

2399 To be compared 

2400 atol : float 

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

2402 

2403 Returns 

2404 ----------- 

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

2406 """ 

2407 # 

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

2409 

2410 

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

2412 """ 

2413 Non-overwritable mapping of string keys to functions. 

2414 

2415 This allows external packages to register additional implementations 

2416 of common functionality without risk of breaking implementations provided 

2417 by trimesh. 

2418 

2419 See trimesh.voxel.morphology for example usage. 

2420 """ 

2421 

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

2423 self._dict = {} 

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

2425 self[k] = v 

2426 

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

2428 return self._dict[key] 

2429 

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

2431 if not isinstance(key, str): 

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

2433 if key in self: 

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

2435 if not callable(value): 

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

2437 self._dict[key] = value 

2438 

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

2440 return iter(self._dict) 

2441 

2442 def __len__(self) -> int: 

2443 return len(self._dict) 

2444 

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

2446 return key in self._dict 

2447 

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

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

2450 

2451 

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

2453 """ 

2454 Try to decode byte input as a string. 

2455 

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

2457 uses charset_normalizer to try another guess before failing. 

2458 

2459 Parameters 

2460 ------------ 

2461 text : bytes 

2462 Data that might be a string 

2463 initial : str 

2464 Initial guess for text encoding. 

2465 

2466 Returns 

2467 ------------ 

2468 decoded : str 

2469 Data as a string 

2470 """ 

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

2472 if isinstance(text, str): 

2473 return text 

2474 

2475 try: 

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

2477 return text.decode(initial) 

2478 except UnicodeDecodeError: 

2479 # detect different file encodings 

2480 from charset_normalizer import detect as charset_normalizer_detect 

2481 

2482 # try to detect the encoding of the file 

2483 # only look at the first 1000 characters for speed 

2484 detect = charset_normalizer_detect(text[:1000]) 

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

2486 log.debug( 

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

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

2489 ) 

2490 ) 

2491 # try to decode again ignoring errors 

2492 # if detect returned nothing just use the initial guess 

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

2494 

2495 

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

2497 """ 

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

2499 

2500 Parameters 

2501 ----------- 

2502 text : any 

2503 Input to be converted to ASCII string 

2504 

2505 Returns 

2506 ----------- 

2507 ascii : str 

2508 Input as an ASCII string 

2509 """ 

2510 if hasattr(text, "encode"): 

2511 # case for existing strings 

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

2513 elif hasattr(text, "decode"): 

2514 # case for bytes 

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

2516 # otherwise just wrap as a string 

2517 return str(text) 

2518 

2519 

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

2521 """ 

2522 Check if connected 2D points are counterclockwise. 

2523 

2524 Parameters 

2525 ----------- 

2526 points : (n, 2) float 

2527 Connected points on a plane 

2528 return_all : bool 

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

2530 

2531 Returns 

2532 ---------- 

2533 ccw : bool 

2534 True if points are counter-clockwise 

2535 area : float 

2536 Only returned if `return_centroid` 

2537 centroid : (2,) float 

2538 Centroid of the polygon. 

2539 """ 

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

2541 

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

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

2544 

2545 # the "shoelace formula" 

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

2547 # the area of the polygon 

2548 area = product.sum() / 2.0 

2549 # check the sign of the area 

2550 ccw = area < 0.0 

2551 

2552 if not return_all: 

2553 return ccw 

2554 

2555 # the centroid of the polygon uses the same formula 

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

2557 6.0 * area 

2558 ) 

2559 

2560 return ccw, area, centroid 

2561 

2562 

2563def unique_name( 

2564 start: str | None, 

2565 contains: Collection[str], 

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

2567) -> str: 

2568 """ 

2569 Deterministically generate a unique name not 

2570 contained in a dict, set or other grouping with 

2571 `__includes__` defined. Will create names of the 

2572 form "start_10" and increment accordingly. 

2573 

2574 Parameters 

2575 ----------- 

2576 start : str 

2577 Initial guess for name. 

2578 contains : dict, set, or list 

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

2580 counts : None or dict 

2581 Maps name starts encountered before to increments in 

2582 order to speed up finding a unique name as otherwise 

2583 it potentially has to iterate through all of contains. 

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

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

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

2587 

2588 Returns 

2589 --------- 

2590 unique : str 

2591 A name that is not contained in `contains` 

2592 """ 

2593 # exit early if name is not in bundle 

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

2595 return start 

2596 

2597 # start checking with zero index unless found 

2598 if counts is None: 

2599 increment = 0 

2600 else: 

2601 increment = counts.get(start, 0) 

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

2603 formatter = start + "_{}" 

2604 # split by our delimiter once 

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

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

2607 try: 

2608 # start incrementing from the existing 

2609 # trailing value 

2610 # if it is not an integer this will fail 

2611 increment = int(split[1]) 

2612 # include the first split value 

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

2614 except BaseException: 

2615 pass 

2616 else: 

2617 formatter = "geometry_{}" 

2618 

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

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

2621 check = formatter.format(i) 

2622 if check not in contains: 

2623 if counts is not None: 

2624 counts[start] = i 

2625 return check 

2626 

2627 # this should really never happen since we looped 

2628 # through the full length of contains 

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