Coverage for trimesh/util.py: 87%
781 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-31 23:55 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-31 23:55 +0000
1"""
2Grab bag of utility functions.
3"""
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
31import numpy as np
33from .iteration import chain
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)
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
57# create a default logger
58log: logging.Logger = logging.getLogger(__name__)
60ABC = abc.ABC
61now = time.time
62which = shutil.which
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
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
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()
83def random_generator(seed: Seed = None) -> np.random.Generator:
84 """
85 Get a random generator, optionally seeded for deterministic results.
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.
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)
107def has_module(name: str) -> bool:
108 """
109 Check to see if a module is installed by name without
110 actually importing the module.
112 Parameters
113 ------------
114 name : str
115 The name of the module to check
117 Returns
118 ------------
119 installed : bool
120 True if module is installed
121 """
122 from importlib.util import find_spec
124 return find_spec(name) is not None
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.
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.
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)
154 # allow user to set zero threshold
155 if threshold is None:
156 threshold = TOL_ZERO
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))
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)!")
181 if check_valid:
182 return unit[valid], valid
183 return unit
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 )
198 a = np.asanyarray(a, dtype=np.float64)
199 b = np.asanyarray(b, dtype=np.float64)
200 return np.sqrt(((a - b) ** 2).sum())
203def is_file(obj: Any) -> BoolIsFile:
204 """
205 Check if an object is file-like
207 Parameters
208 ------------
209 obj : object
210 Any object type to be checked
212 Returns
213 -----------
214 is_file : bool
215 True if object is a file
216 """
217 return hasattr(obj, "read") or hasattr(obj, "write")
220def is_pathlib(obj: object) -> bool:
221 """
222 Check if the object is a `pathlib.Path` or subclass.
224 Parameters
225 ------------
226 obj : object
227 Object to be checked
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")
239def is_string(obj: object) -> bool:
240 """
241 DEPRECATED : this is not necessary since we dropped Python 2.
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 )
253 return isinstance(obj, str)
256def is_sequence(obj: Any) -> bool:
257 """
258 Check if an object is a sequence or not.
260 Parameters
261 -------------
262 obj : object
263 Any object type to be checked
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 )
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))
277 # PointCloud objects can look like an array but are not
278 seq = seq and type(obj).__name__ not in ["PointCloud"]
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 != ()
285 return seq
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
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.
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
310 Returns
311 ---------
312 shape_ok : bool
313 True if shape of obj matches query shape
315 Examples
316 ------------------------
317 In [1]: a = np.random.random((100, 3))
319 In [2]: a.shape
320 Out[2]: (100, 3)
322 In [3]: trimesh.util.is_shape(a, (-1, 3))
323 Out[3]: True
325 In [4]: trimesh.util.is_shape(a, (-1, 3, 5))
326 Out[4]: False
328 In [5]: trimesh.util.is_shape(a, (100, -1))
329 Out[5]: True
331 In [6]: trimesh.util.is_shape(a, (-1, (3, 4)))
332 Out[6]: True
334 In [7]: trimesh.util.is_shape(a, (-1, (4, 5)))
335 Out[7]: False
336 """
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
344 # empty lists with any flexible dimensions match
345 if len(obj) == 0 and -1 in shape:
346 return True
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
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
376 # since none of the checks failed the obj.shape
377 # matches the pattern
378 return True
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.
386 Useful for wrapping functions which sometimes return single
387 objects and other times return lists of objects.
389 Parameters
390 -------------
391 obj : object
392 An object to be made a sequence
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]
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.
413 If the vector lies on the plane all vectors with negative Y
414 will be reversed.
416 If the vector has a zero Z and Y value vectors with a
417 negative X value will be reversed.
419 Parameters
420 ------------
421 vectors : (n, 3) float
422 Input vectors
423 return_sign : bool
424 Return the sign mask or not
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)
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))
445 signs = np.ones(len(vectors), dtype=np.float64)
446 # negative Y values are reversed
447 signs[negative[:, 1]] = -1.0
449 # zero Y and negative X are reversed
450 signs[np.logical_and(zero[:, 1], negative[:, 0])] = -1.0
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
470 else:
471 raise ValueError("vectors must be (n, 3)!")
473 # apply the signs to the vectors
474 oriented = vectors * signs.reshape((-1, 1))
476 if return_sign:
477 return oriented, signs
479 return oriented
482def vector_to_spherical(cartesian: ArrayLike) -> NDArray2D[np.float64]:
483 """
484 Convert a set of cartesian points to (n, 2) spherical unit
485 vectors.
487 Parameters
488 ------------
489 cartesian : (n, 3) float
490 Points in space
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)!")
501 unit, valid = unitize(cartesian, check_valid=True)
502 unit[np.abs(unit) < TOL_MERGE] = 0.0
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
510def spherical_to_vector(spherical: ArrayLike) -> NDArray2D[np.float64]:
511 """
512 Convert an array of `(n, 2)` spherical angles to `(n, 3)` unit vectors.
514 Parameters
515 ------------
516 spherical : (n , 2) float
517 Angles, in radians
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)!")
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))
534def pairwise(iterable: Iterable[Any]):
535 """
536 For an iterable, group values into pairs.
538 Parameters
539 ------------
540 iterable : (m, ) list
541 A sequence of values
543 Returns
544 -----------
545 pairs: (n, 2)
546 Pairs of sequential values
548 Example
549 -----------
550 In [1]: data
551 Out[1]: [0, 1, 2, 3, 4, 5, 6]
553 In [2]: list(trimesh.util.pairwise(data))
554 Out[2]: [(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6)]
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
565 # if we have a normal iterable use itertools
566 import itertools
568 a, b = itertools.tee(iterable)
569 # pop the first element of the second item
570 next(b)
572 return zip(a, b)
575multi_dot = np.linalg.multi_dot
578def diagonal_dot(a: ArrayLike, b: ArrayLike) -> np.ndarray[tuple[int], np.dtype[Any]]:
579 """
580 Dot product by row of a and b.
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.
587 Comparing performance of some equivalent versions:
588 ```
589 In [1]: import numpy as np; import trimesh
591 In [2]: a = np.random.random((10000, 3))
593 In [3]: b = np.random.random((10000, 3))
595 In [4]: %timeit (a * b).sum(axis=1)
596 1000 loops, best of 3: 181 us per loop
598 In [5]: %timeit np.einsum('ij,ij->i', a, b)
599 10000 loops, best of 3: 62.7 us per loop
601 In [6]: %timeit np.diag(np.dot(a, b.T))
602 1 loop, best of 3: 429 ms per loop
604 In [7]: %timeit np.dot(a * b, np.ones(a.shape[1]))
605 10000 loops, best of 3: 61.3 us per loop
607 In [8]: %timeit trimesh.util.diagonal_dot(a, b)
608 10000 loops, best of 3: 55.2 us per loop
609 ```
611 Parameters
612 ------------
613 a : (m, d) float
614 First array
615 b : (m, d) float
616 Second array
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])
632def row_norm(data: NDArray2D[Any]) -> NDArray1D[np.float64]:
633 """
634 Compute the norm per-row of a numpy array.
636 This is identical to np.linalg.norm(data, axis=1) but roughly
637 three times faster due to being less general.
639 In [3]: %timeit trimesh.util.row_norm(a)
640 76.3 us +/- 651 ns per loop
642 In [4]: %timeit np.linalg.norm(a, axis=1)
643 220 us +/- 5.41 us per loop
645 Parameters
646 -------------
647 data : (n, d) float
648 Input 2D data to calculate per-row norm of
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]))
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.
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?
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
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)!")
695 if return_2D:
696 return points, is_2D
698 return points
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.
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
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!")
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])
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
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.
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
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!")
749 count = np.asanyarray(count, dtype=np.int64)
750 if count.shape == ():
751 count = np.tile(count, bounds.shape[1])
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
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.
769 Can be called like the regular dict(pairs) constructor
771 Parameters
772 ------------
773 pairs: (n, 2) array of key, value pairs
775 Returns
776 ----------
777 result: dict, with all values stored (rather than last with regular dict)
779 """
780 result = collections.defaultdict(list)
781 for k, v in pairs:
782 result[k].append(v)
783 return result
786def tolist(data: object) -> Any:
787 """
788 Ensure that any arrays or dicts passed containing
789 numpy arrays are properly converted to lists
791 Parameters
792 -------------
793 data : any
794 Usually a dict with some numpy arrays as values
796 Returns
797 ----------
798 result : any
799 JSON-serializable version of data
800 """
801 result = json.loads(jsonify(data))
802 return result
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
823def distance_to_end(file_obj: Stream) -> int:
824 """
825 For an open file object how far is it to the end
827 Parameters
828 ------------
829 file_obj: open file-like object
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
843def decimal_to_digits(decimal: Floating, min_digits: Integer | None = None) -> int:
844 """
845 Return the number of digits to the first nonzero decimal.
847 Parameters
848 -----------
849 decimal: float
850 min_digits: int, minimum number of digits to return
852 Returns
853 -----------
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)
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.
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 """
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 ]
907 # make sure we log warnings from the warnings module
908 logging.captureWarnings(capture_warnings)
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
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
937 # if no handler was passed use a StreamHandler
938 if handler is None:
939 handler = logging.StreamHandler()
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))
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())
951 # add the warnings logging
952 loggers.add(logging.getLogger("py.warnings"))
954 # disable pyembree warnings
955 logging.getLogger("pyembree").disabled = True
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 }
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
977 # loop through all available loggers
978 for logger in loggers_dict.values():
979 logger.addHandler(handler)
980 logger.setLevel(int(level))
982 # set nicer numpy print options
983 np.set_printoptions(precision=5, suppress=True)
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.
991 Parameters
992 ------------
993 indices : (m,) any
994 List of items to be stacked
996 Returns
997 ---------
998 stacked : (n, 2) any
999 Stacked items
1001 Examples
1002 ----------
1003 In [1]: trimesh.util.stack_lines([0, 1, 2])
1004 Out[1]:
1005 array([[0, 1],
1006 [1, 2]])
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]])
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]])
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)
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.
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
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])
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)
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)
1081 return vertices, faces
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.
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.
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)
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 )
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")
1134 # allow a value to be repeated in a value format
1135 repeats = value_format.count("{")
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)
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)
1155 # expand format string to whole array
1156 format_str *= len(array)
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)
1162 # run the format operation and remove the extra delimiters
1163 formatted = format_str.format(*shaped)[:-end_junk]
1165 return formatted
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.
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.
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)
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 )
1215 # abort for unstructured arrays
1216 if array.dtype.names is None:
1217 raise ValueError("array is not structured, use array_to_string instead")
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 )
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
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)
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)
1255 # run the format operation and remove the extra delimiters
1256 formatted = format_str.format(*flattened)[: -len(row_delim)]
1258 return formatted
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.
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'
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
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
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.
1311 Parameters
1312 ------------
1313 store : dict
1314 Dictionary with data
1316 Returns
1317 ---------
1318 result : dict
1319 Values are untouched but keys that were bytes
1320 are converted to ASCII strings.
1322 Example
1323 -----------
1324 In [1]: d
1325 Out[1]: {1020: 'nah', b'hi': 'stuff'}
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
1340def comment_strip(text: str, starts_with: str = "#", new_line: str = "\n") -> str:
1341 """
1342 Strip comments from a text block.
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
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
1362 # start by splitting into chunks by the comment indicator
1363 split = (text + new_line).split(starts_with)
1365 # special case files that start with a comment
1366 if text.startswith(starts_with):
1367 lead = ""
1368 else:
1369 lead = split[0]
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()
1382 return result
1385def encoded_to_array(encoded: ArrayLike | dict[Any, Any]) -> NDArray:
1386 """
1387 Turn a dictionary with base64 encoded strings back into a numpy array.
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
1398 Returns
1399 ----------
1400 array
1401 """
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")
1410 encoded_dict = decode_keys(encoded)
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
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.
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
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
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__")]
1465def type_named(obj: object, name: str) -> type:
1466 """
1467 Similar to the type() builtin, but looks in class bases
1468 for named instance.
1470 Parameters
1471 ------------
1472 obj : any
1473 Object to look for class of
1474 name : str
1475 Name of class
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)
1492def concatenate(a, b=None) -> "trimesh.parent.Geometry":
1493 """
1494 Concatenate two or more meshes.
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
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)
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
1525 return Trimesh()
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")]
1530 # if we have more
1531 if len(is_path) > len(is_mesh):
1532 from .path.util import concatenate as concatenate_path
1534 return concatenate_path(is_path)
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
1541 return Trimesh()
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")
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 )
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
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
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
1573 metadata = {}
1574 try:
1575 _ = [metadata.update(deepcopy(m.metadata) for m in is_mesh)]
1576 except BaseException:
1577 pass
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 )
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 )
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 )
1622 try:
1623 result._source = deepcopy(is_mesh[0].source)
1624 except BaseException:
1625 pass
1627 return result
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.
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
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)
1665 if len(faces_sequence) == 0:
1666 return []
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)
1672 faces = []
1673 vertices = []
1674 normals = []
1675 visuals = []
1677 # for reindexing faces
1678 mask = np.arange(len(original_vertices))
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
1696 current = original_faces[index]
1697 unique = np.unique(current.reshape(-1))
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])
1705 visuals.append(mesh.visual.face_subset(index))
1707 if len(vertices) == 0:
1708 return []
1710 # we use type(mesh) rather than importing Trimesh from base
1711 # to avoid a circular import
1712 trimesh_type = type_named(mesh, "Trimesh")
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)
1733 return appended
1735 if visuals is None:
1736 visuals = [None] * len(vertices)
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 ]
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]
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]
1763 if only_watertight:
1764 # return only the watertight meshes
1765 return [i for i, w in zip(result, watertight) if w]
1767 return result
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
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)
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.
1802 Parameters
1803 --------------
1804 obj : list, dict
1805 A JSON-serializable blob
1806 kwargs : dict
1807 Passed to json.dumps
1809 Returns
1810 --------------
1811 dumped : str
1812 JSON dump of obj
1813 """
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)
1830 # run the dumps using our encoder
1831 return json.dumps(obj, cls=EdgeEncoder, **kwargs)
1834def convert_like(item, like):
1835 """
1836 Convert an item to have the dtype of another item
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
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)
1854 # if it's already the desired type just return it
1855 if isinstance(item, like.__class__) or like is None:
1856 return item
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]
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
1871 return wkt.loads(item)
1873 # otherwise just run the conversion
1874 item = like.__class__(item)
1876 return item
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.
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), ...]
1891 Returns
1892 ---------
1893 tree : Rtree
1894 Tree containing bounds by index
1895 """
1896 import rtree
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)!")
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)
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 )
1922def wrap_as_stream(item: str | bytes) -> StringIO | BytesIO:
1923 """
1924 Wrap a string or bytes object as a file object.
1926 Parameters
1927 ------------
1928 item: str or bytes
1929 Item to be wrapped
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!")
1943def sigfig_round(values: ArrayLike, sigfig: ArrayLike = 1) -> NDArray1D[np.float64]:
1944 """
1945 Round a single value to a specified number of significant figures.
1947 Parameters
1948 ------------
1949 values : float
1950 Value to be rounded
1951 sigfig : int
1952 Number of significant figures to reduce to
1954 Returns
1955 ----------
1956 rounded : float
1957 Value rounded to the specified number of significant figures
1960 Examples
1961 ----------
1962 In [1]: trimesh.util.round_sigfig(-232453.00014045456, 1)
1963 Out[1]: -200000.0
1965 In [2]: trimesh.util.round_sigfig(.00014045456, 1)
1966 Out[2]: 0.0001
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)
1974 return rounded
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.
1985 Parameters
1986 ------------
1987 values : (n,) float or int
1988 Array of values
1989 sigfig : (n,) int
1990 Number of significant figures to keep
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)
2003 if sigfig.shape != values.shape:
2004 raise ValueError("sigfig must match identifier")
2006 exponent = np.zeros(len(values))
2007 nonzero = np.abs(values) > TOL_ZERO
2008 exponent[nonzero] = np.floor(np.log10(np.abs(values[nonzero])))
2010 multiplier = exponent - sigfig + 1
2011 as_int = (values / (10**multiplier)).round().astype(np.int64)
2013 return as_int, multiplier
2016# skip ZIP members once declared uncompressed sizes exceed this
2017_ARCHIVE_SKIP_SIZE = 32 * 1024**3 # 32 GiB
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.
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.
2032 Parameters
2033 ------------
2034 file_obj : file-like
2035 Containing compressed data.
2036 file_type : str
2037 File extension, 'zip', 'tar.gz', etc.
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)
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
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
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!")
2090def compress(
2091 info: Mapping[str, str | bytes | Stream],
2092 **kwargs: Any,
2093) -> bytes:
2094 """
2095 Compress data stored in a dict.
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
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)
2130 Parameters
2131 ------------
2132 file_name : str
2133 File name
2134 special : list of str
2135 Multipart extensions
2136 eg: ['tar.bz2', 'tar.gz']
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)
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]
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.
2161 Processes all strips at once using np.concatenate and is significantly
2162 faster than loop-based methods.
2164 From the OpenGL programming guide describing a single triangle
2165 strip [v0, v1, v2, v3, v4]:
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.
2173 Parameters
2174 ------------
2175 strips: (n,) list of (m,) int
2176 Vertex indices
2178 Returns
2179 ------------
2180 faces : (m, 3) int
2181 Vertex indices representing triangles
2182 """
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)
2190 # slice the blob into rough triangles
2191 tri = np.array([blob[:-2], blob[1:-1], blob[2:]], dtype=np.int64).T
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
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]
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])
2215 return tri
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
2224 Parameters
2225 ----------
2226 fans: (n,) list of (m + 2,) int
2227 Vertex indices
2229 Returns
2230 -------
2231 faces: (m, 3) int
2232 Vertex indices representing triangles
2233 """
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)
2242def vstack_empty(tup: Iterable[ArrayLike]) -> NDArray:
2243 """
2244 A thin wrapper for numpy.vstack that ignores empty lists.
2246 Parameters
2247 ------------
2248 tup : tuple or list of arrays
2249 With the same number of columns
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)
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.
2278 If a file is open in text mode and bytes are
2279 passed decode bytes to str and write.
2281 Assumes binary mode if file_obj does not have
2282 a 'mode' attribute (e.g. io.BufferedRandom).
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)
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
2307def unique_id(length: Integer = 12) -> str:
2308 """
2309 Generate a random alphaNumber unique identifier
2310 using UUID logic.
2312 Parameters
2313 ------------
2314 length : int
2315 Length of desired identifier
2317 Returns
2318 ------------
2319 unique : str
2320 Unique alphaNumber identifier
2321 """
2322 return uuid.UUID(int=random.getrandbits(128), version=4).hex[:length]
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.
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.
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!")
2352 z_norm = np.linalg.norm(z)
2353 if z_norm < epsilon:
2354 return np.eye(3)
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)
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)
2383 return result
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.
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"
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"
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)
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.
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"
2430 Returns
2431 -----------
2432 bool indicating if all elements are within `atol`.
2433 """
2434 #
2435 return bool(float(np.ptp(a - b)) < atol)
2438class FunctionRegistry(Mapping[str, Callable[..., Any]]):
2439 """
2440 Non-overwritable mapping of string keys to functions.
2442 This allows external packages to register additional implementations
2443 of common functionality without risk of breaking implementations provided
2444 by trimesh.
2446 See trimesh.voxel.morphology for example usage.
2447 """
2449 def __init__(self, **kwargs: Callable[..., Any]) -> None:
2450 self._dict = {}
2451 for k, v in kwargs.items():
2452 self[k] = v
2454 def __getitem__(self, key: str) -> Callable[..., Any]:
2455 return self._dict[key]
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
2466 def __iter__(self) -> Iterator[str]:
2467 return iter(self._dict)
2469 def __len__(self) -> int:
2470 return len(self._dict)
2472 def __contains__(self, key: object) -> bool:
2473 return key in self._dict
2475 def __call__(self, key: str, *args: object, **kwargs: object) -> Any:
2476 return self[key](*args, **kwargs)
2479def decode_text(text: str | bytes, initial: str = "utf-8") -> str:
2480 """
2481 Try to decode byte input as a string.
2483 Tries initial guess (UTF-8) then if that fails it
2484 uses charset_normalizer to try another guess before failing.
2486 Parameters
2487 ------------
2488 text : bytes
2489 Data that might be a string
2490 initial : str
2491 Initial guess for text encoding.
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
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
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")
2523def to_ascii(text: Any) -> str:
2524 """
2525 Force a string or other to ASCII text ignoring errors.
2527 Parameters
2528 -----------
2529 text : any
2530 Input to be converted to ASCII string
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)
2547def is_ccw(points: ArrayLike, return_all: bool = False):
2548 """
2549 Check if connected 2D points are counterclockwise.
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.
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)
2569 if len(points.shape) != 2 or points.shape[1] != 2:
2570 raise ValueError("only defined for `(n, 2)` points")
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
2579 if not return_all:
2580 return ccw
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 )
2587 return ccw, area, centroid
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.
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!
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
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_{}"
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
2654 # this should really never happen since we looped
2655 # through the full length of contains
2656 raise ValueError("Unable to establish unique name!")