Coverage for trimesh/util.py: 84%
786 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-24 04:40 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-24 04:40 +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 io import BytesIO, StringIO
28from typing import TYPE_CHECKING
30import numpy as np
32from .iteration import chain
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)
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
55# create a default logger
56log: logging.Logger = logging.getLogger(__name__)
58ABC = abc.ABC
59now = time.time
60which = shutil.which
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
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
77def has_module(name: str) -> bool:
78 """
79 Check to see if a module is installed by name without
80 actually importing the module.
82 Parameters
83 ------------
84 name : str
85 The name of the module to check
87 Returns
88 ------------
89 installed : bool
90 True if module is installed
91 """
92 from importlib.util import find_spec
94 return find_spec(name) is not None
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.
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.
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)
124 # allow user to set zero threshold
125 if threshold is None:
126 threshold = TOL_ZERO
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))
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)!")
151 if check_valid:
152 return unit[valid], valid
153 return unit
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 )
168 a = np.asanyarray(a, dtype=np.float64)
169 b = np.asanyarray(b, dtype=np.float64)
170 return np.sqrt(((a - b) ** 2).sum())
173def is_file(obj: Any) -> BoolIsFile:
174 """
175 Check if an object is file-like
177 Parameters
178 ------------
179 obj : object
180 Any object type to be checked
182 Returns
183 -----------
184 is_file : bool
185 True if object is a file
186 """
187 return hasattr(obj, "read") or hasattr(obj, "write")
190def is_pathlib(obj: object) -> bool:
191 """
192 Check if the object is a `pathlib.Path` or subclass.
194 Parameters
195 ------------
196 obj : object
197 Object to be checked
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")
209def is_string(obj: object) -> bool:
210 """
211 DEPRECATED : this is not necessary since we dropped Python 2.
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 )
223 return isinstance(obj, str)
226def is_sequence(obj: Any) -> bool:
227 """
228 Check if an object is a sequence or not.
230 Parameters
231 -------------
232 obj : object
233 Any object type to be checked
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 )
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))
247 # PointCloud objects can look like an array but are not
248 seq = seq and type(obj).__name__ not in ["PointCloud"]
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 != ()
255 return seq
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
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.
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
280 Returns
281 ---------
282 shape_ok : bool
283 True if shape of obj matches query shape
285 Examples
286 ------------------------
287 In [1]: a = np.random.random((100, 3))
289 In [2]: a.shape
290 Out[2]: (100, 3)
292 In [3]: trimesh.util.is_shape(a, (-1, 3))
293 Out[3]: True
295 In [4]: trimesh.util.is_shape(a, (-1, 3, 5))
296 Out[4]: False
298 In [5]: trimesh.util.is_shape(a, (100, -1))
299 Out[5]: True
301 In [6]: trimesh.util.is_shape(a, (-1, (3, 4)))
302 Out[6]: True
304 In [7]: trimesh.util.is_shape(a, (-1, (4, 5)))
305 Out[7]: False
306 """
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
314 # empty lists with any flexible dimensions match
315 if len(obj) == 0 and -1 in shape:
316 return True
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
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
346 # since none of the checks failed the obj.shape
347 # matches the pattern
348 return True
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.
356 Useful for wrapping functions which sometimes return single
357 objects and other times return lists of objects.
359 Parameters
360 -------------
361 obj : object
362 An object to be made a sequence
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]
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.
383 If the vector lies on the plane all vectors with negative Y
384 will be reversed.
386 If the vector has a zero Z and Y value vectors with a
387 negative X value will be reversed.
389 Parameters
390 ------------
391 vectors : (n, 3) float
392 Input vectors
393 return_sign : bool
394 Return the sign mask or not
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)
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))
415 signs = np.ones(len(vectors), dtype=np.float64)
416 # negative Y values are reversed
417 signs[negative[:, 1]] = -1.0
419 # zero Y and negative X are reversed
420 signs[np.logical_and(zero[:, 1], negative[:, 0])] = -1.0
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
440 else:
441 raise ValueError("vectors must be (n, 3)!")
443 # apply the signs to the vectors
444 oriented = vectors * signs.reshape((-1, 1))
446 if return_sign:
447 return oriented, signs
449 return oriented
452def vector_to_spherical(cartesian: ArrayLike) -> NDArray2D[np.float64]:
453 """
454 Convert a set of cartesian points to (n, 2) spherical unit
455 vectors.
457 Parameters
458 ------------
459 cartesian : (n, 3) float
460 Points in space
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)!")
471 unit, valid = unitize(cartesian, check_valid=True)
472 unit[np.abs(unit) < TOL_MERGE] = 0.0
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
480def spherical_to_vector(spherical: ArrayLike) -> NDArray2D[np.float64]:
481 """
482 Convert an array of `(n, 2)` spherical angles to `(n, 3)` unit vectors.
484 Parameters
485 ------------
486 spherical : (n , 2) float
487 Angles, in radians
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)!")
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))
504def pairwise(iterable: Iterable[Any]):
505 """
506 For an iterable, group values into pairs.
508 Parameters
509 ------------
510 iterable : (m, ) list
511 A sequence of values
513 Returns
514 -----------
515 pairs: (n, 2)
516 Pairs of sequential values
518 Example
519 -----------
520 In [1]: data
521 Out[1]: [0, 1, 2, 3, 4, 5, 6]
523 In [2]: list(trimesh.util.pairwise(data))
524 Out[2]: [(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6)]
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
535 # if we have a normal iterable use itertools
536 import itertools
538 a, b = itertools.tee(iterable)
539 # pop the first element of the second item
540 next(b)
542 return zip(a, b)
545multi_dot = np.linalg.multi_dot
548def diagonal_dot(a: ArrayLike, b: ArrayLike) -> np.ndarray[tuple[int], np.dtype[Any]]:
549 """
550 Dot product by row of a and b.
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.
557 Comparing performance of some equivalent versions:
558 ```
559 In [1]: import numpy as np; import trimesh
561 In [2]: a = np.random.random((10000, 3))
563 In [3]: b = np.random.random((10000, 3))
565 In [4]: %timeit (a * b).sum(axis=1)
566 1000 loops, best of 3: 181 us per loop
568 In [5]: %timeit np.einsum('ij,ij->i', a, b)
569 10000 loops, best of 3: 62.7 us per loop
571 In [6]: %timeit np.diag(np.dot(a, b.T))
572 1 loop, best of 3: 429 ms per loop
574 In [7]: %timeit np.dot(a * b, np.ones(a.shape[1]))
575 10000 loops, best of 3: 61.3 us per loop
577 In [8]: %timeit trimesh.util.diagonal_dot(a, b)
578 10000 loops, best of 3: 55.2 us per loop
579 ```
581 Parameters
582 ------------
583 a : (m, d) float
584 First array
585 b : (m, d) float
586 Second array
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])
602def row_norm(data: NDArray2D[Any]) -> NDArray1D[np.float64]:
603 """
604 Compute the norm per-row of a numpy array.
606 This is identical to np.linalg.norm(data, axis=1) but roughly
607 three times faster due to being less general.
609 In [3]: %timeit trimesh.util.row_norm(a)
610 76.3 us +/- 651 ns per loop
612 In [4]: %timeit np.linalg.norm(a, axis=1)
613 220 us +/- 5.41 us per loop
615 Parameters
616 -------------
617 data : (n, d) float
618 Input 2D data to calculate per-row norm of
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]))
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.
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?
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
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)!")
665 if return_2D:
666 return points, is_2D
668 return points
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.
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
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!")
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])
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
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.
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
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!")
719 count = np.asanyarray(count, dtype=np.int64)
720 if count.shape == ():
721 count = np.tile(count, bounds.shape[1])
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
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.
739 Can be called like the regular dict(pairs) constructor
741 Parameters
742 ------------
743 pairs: (n, 2) array of key, value pairs
745 Returns
746 ----------
747 result: dict, with all values stored (rather than last with regular dict)
749 """
750 result = collections.defaultdict(list)
751 for k, v in pairs:
752 result[k].append(v)
753 return result
756def tolist(data: object) -> Any:
757 """
758 Ensure that any arrays or dicts passed containing
759 numpy arrays are properly converted to lists
761 Parameters
762 -------------
763 data : any
764 Usually a dict with some numpy arrays as values
766 Returns
767 ----------
768 result : any
769 JSON-serializable version of data
770 """
771 result = json.loads(jsonify(data))
772 return result
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
793def distance_to_end(file_obj: Stream) -> int:
794 """
795 For an open file object how far is it to the end
797 Parameters
798 ------------
799 file_obj: open file-like object
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
813def decimal_to_digits(decimal: Floating, min_digits: Integer | None = None) -> int:
814 """
815 Return the number of digits to the first nonzero decimal.
817 Parameters
818 -----------
819 decimal: float
820 min_digits: int, minimum number of digits to return
822 Returns
823 -----------
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)
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.
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 """
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 ]
877 # make sure we log warnings from the warnings module
878 logging.captureWarnings(capture_warnings)
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
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
907 # if no handler was passed use a StreamHandler
908 if handler is None:
909 handler = logging.StreamHandler()
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))
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())
921 # add the warnings logging
922 loggers.add(logging.getLogger("py.warnings"))
924 # disable pyembree warnings
925 logging.getLogger("pyembree").disabled = True
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 }
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
947 # loop through all available loggers
948 for logger in loggers_dict.values():
949 logger.addHandler(handler)
950 logger.setLevel(int(level))
952 # set nicer numpy print options
953 np.set_printoptions(precision=5, suppress=True)
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.
961 Parameters
962 ------------
963 indices : (m,) any
964 List of items to be stacked
966 Returns
967 ---------
968 stacked : (n, 2) any
969 Stacked items
971 Examples
972 ----------
973 In [1]: trimesh.util.stack_lines([0, 1, 2])
974 Out[1]:
975 array([[0, 1],
976 [1, 2]])
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]])
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]])
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)
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.
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
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])
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)
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)
1051 return vertices, faces
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.
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.
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)
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 )
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")
1104 # allow a value to be repeated in a value format
1105 repeats = value_format.count("{")
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)
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)
1125 # expand format string to whole array
1126 format_str *= len(array)
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)
1132 # run the format operation and remove the extra delimiters
1133 formatted = format_str.format(*shaped)[:-end_junk]
1135 return formatted
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.
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.
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)
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 )
1185 # abort for unstructured arrays
1186 if array.dtype.names is None:
1187 raise ValueError("array is not structured, use array_to_string instead")
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 )
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
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)
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)
1225 # run the format operation and remove the extra delimiters
1226 formatted = format_str.format(*flattened)[: -len(row_delim)]
1228 return formatted
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.
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'
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
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
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.
1281 Parameters
1282 ------------
1283 store : dict
1284 Dictionary with data
1286 Returns
1287 ---------
1288 result : dict
1289 Values are untouched but keys that were bytes
1290 are converted to ASCII strings.
1292 Example
1293 -----------
1294 In [1]: d
1295 Out[1]: {1020: 'nah', b'hi': 'stuff'}
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
1310def comment_strip(text: str, starts_with: str = "#", new_line: str = "\n") -> str:
1311 """
1312 Strip comments from a text block.
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
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
1332 # start by splitting into chunks by the comment indicator
1333 split = (text + new_line).split(starts_with)
1335 # special case files that start with a comment
1336 if text.startswith(starts_with):
1337 lead = ""
1338 else:
1339 lead = split[0]
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()
1352 return result
1355def encoded_to_array(encoded: ArrayLike | dict[Any, Any]) -> NDArray:
1356 """
1357 Turn a dictionary with base64 encoded strings back into a numpy array.
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
1368 Returns
1369 ----------
1370 array
1371 """
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")
1380 encoded_dict = decode_keys(encoded)
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
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.
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
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
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__")]
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.
1440 Parameters
1441 ------------
1442 obj : any
1443 Object to look for class of
1444 name : str
1445 Nnme of class
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)
1462def concatenate(a, b=None) -> "trimesh.parent.Geometry":
1463 """
1464 Concatenate two or more meshes.
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
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)
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
1495 return Trimesh()
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")]
1500 # if we have more
1501 if len(is_path) > len(is_mesh):
1502 from .path.util import concatenate as concatenate_path
1504 return concatenate_path(is_path)
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
1511 return Trimesh()
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")
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 )
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
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
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
1543 metadata = {}
1544 try:
1545 _ = [metadata.update(deepcopy(m.metadata) for m in is_mesh)]
1546 except BaseException:
1547 pass
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 )
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 )
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 )
1593 try:
1594 result._source = deepcopy(is_mesh[0].source)
1595 except BaseException:
1596 pass
1598 return result
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.
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
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)
1636 if len(faces_sequence) == 0:
1637 return []
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)
1643 faces = []
1644 vertices = []
1645 normals = []
1646 visuals = []
1648 # for reindexing faces
1649 mask = np.arange(len(original_vertices))
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
1667 current = original_faces[index]
1668 unique = np.unique(current.reshape(-1))
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])
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
1683 if len(vertices) == 0:
1684 return []
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
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)
1710 return appended
1712 if visuals is None:
1713 visuals = [None] * len(vertices)
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 ]
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]
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]
1740 if only_watertight:
1741 # return only the watertight meshes
1742 return [i for i, w in zip(result, watertight) if w]
1744 return result
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
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)
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.
1779 Parameters
1780 --------------
1781 obj : list, dict
1782 A JSON-serializable blob
1783 kwargs : dict
1784 Passed to json.dumps
1786 Returns
1787 --------------
1788 dumped : str
1789 JSON dump of obj
1790 """
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)
1802 # run the dumps using our encoder
1803 return json.dumps(obj, cls=EdgeEncoder, **kwargs)
1806def convert_like(item, like):
1807 """
1808 Convert an item to have the dtype of another item
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
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)
1826 # if it's already the desired type just return it
1827 if isinstance(item, like.__class__) or like is None:
1828 return item
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]
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
1843 return wkt.loads(item)
1845 # otherwise just run the conversion
1846 item = like.__class__(item)
1848 return item
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.
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), ...]
1863 Returns
1864 ---------
1865 tree : Rtree
1866 Tree containing bounds by index
1867 """
1868 import rtree
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)!")
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)
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 )
1894def wrap_as_stream(item: str | bytes) -> StringIO | BytesIO:
1895 """
1896 Wrap a string or bytes object as a file object.
1898 Parameters
1899 ------------
1900 item: str or bytes
1901 Item to be wrapped
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!")
1915def sigfig_round(values: ArrayLike, sigfig: ArrayLike = 1) -> NDArray1D[np.float64]:
1916 """
1917 Round a single value to a specified number of significant figures.
1919 Parameters
1920 ------------
1921 values : float
1922 Value to be rounded
1923 sigfig : int
1924 Number of significant figures to reduce to
1926 Returns
1927 ----------
1928 rounded : float
1929 Value rounded to the specified number of significant figures
1932 Examples
1933 ----------
1934 In [1]: trimesh.util.round_sigfig(-232453.00014045456, 1)
1935 Out[1]: -200000.0
1937 In [2]: trimesh.util.round_sigfig(.00014045456, 1)
1938 Out[2]: 0.0001
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)
1946 return rounded
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.
1957 Parameters
1958 ------------
1959 values : (n,) float or int
1960 Array of values
1961 sigfig : (n,) int
1962 Number of significant figures to keep
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)
1975 if sigfig.shape != values.shape:
1976 raise ValueError("sigfig must match identifier")
1978 exponent = np.zeros(len(values))
1979 nonzero = np.abs(values) > TOL_ZERO
1980 exponent[nonzero] = np.floor(np.log10(np.abs(values[nonzero])))
1982 multiplier = exponent - sigfig + 1
1983 as_int = (values / (10**multiplier)).round().astype(np.int64)
1985 return as_int, multiplier
1988# cap on total uncompressed bytes from a single archive
1989MAX_ARCHIVE_SIZE = 8 * 1024**3 # 8 GiB
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.
2000 Total uncompressed size is capped at `MAX_ARCHIVE_SIZE`; reads stop
2001 one byte past the budget rather than trusting declared member sizes.
2003 Parameters
2004 ------------
2005 file_obj : file-like
2006 Containing compressed data.
2007 file_type : str
2008 File extension, 'zip', 'tar.gz', etc.
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)
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
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
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!")
2063def compress(
2064 info: Mapping[str, str | bytes | Stream],
2065 **kwargs: Any,
2066) -> bytes:
2067 """
2068 Compress data stored in a dict.
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
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)
2103 Parameters
2104 ------------
2105 file_name : str
2106 File name
2107 special : list of str
2108 Multipart extensions
2109 eg: ['tar.bz2', 'tar.gz']
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)
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]
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.
2134 Processes all strips at once using np.concatenate and is significantly
2135 faster than loop-based methods.
2137 From the OpenGL programming guide describing a single triangle
2138 strip [v0, v1, v2, v3, v4]:
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.
2146 Parameters
2147 ------------
2148 strips: (n,) list of (m,) int
2149 Vertex indices
2151 Returns
2152 ------------
2153 faces : (m, 3) int
2154 Vertex indices representing triangles
2155 """
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)
2163 # slice the blob into rough triangles
2164 tri = np.array([blob[:-2], blob[1:-1], blob[2:]], dtype=np.int64).T
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
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]
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])
2188 return tri
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
2197 Parameters
2198 ----------
2199 fans: (n,) list of (m + 2,) int
2200 Vertex indices
2202 Returns
2203 -------
2204 faces: (m, 3) int
2205 Vertex indices representing triangles
2206 """
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)
2215def vstack_empty(tup: Iterable[ArrayLike]) -> NDArray:
2216 """
2217 A thin wrapper for numpy.vstack that ignores empty lists.
2219 Parameters
2220 ------------
2221 tup : tuple or list of arrays
2222 With the same number of columns
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)
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.
2251 If a file is open in text mode and bytes are
2252 passed decode bytes to str and write.
2254 Assumes binary mode if file_obj does not have
2255 a 'mode' attribute (e.g. io.BufferedRandom).
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)
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
2280def unique_id(length: Integer = 12) -> str:
2281 """
2282 Generate a random alphaNumber unique identifier
2283 using UUID logic.
2285 Parameters
2286 ------------
2287 length : int
2288 Length of desired identifier
2290 Returns
2291 ------------
2292 unique : str
2293 Unique alphaNumber identifier
2294 """
2295 return uuid.UUID(int=random.getrandbits(128), version=4).hex[:length]
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.
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.
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!")
2325 z_norm = np.linalg.norm(z)
2326 if z_norm < epsilon:
2327 return np.eye(3)
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)
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)
2356 return result
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.
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"
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"
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)
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.
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"
2403 Returns
2404 -----------
2405 bool indicating if all elements are within `atol`.
2406 """
2407 #
2408 return bool(float(np.ptp(a - b)) < atol)
2411class FunctionRegistry(Mapping[str, Callable[..., Any]]):
2412 """
2413 Non-overwritable mapping of string keys to functions.
2415 This allows external packages to register additional implementations
2416 of common functionality without risk of breaking implementations provided
2417 by trimesh.
2419 See trimesh.voxel.morphology for example usage.
2420 """
2422 def __init__(self, **kwargs: Callable[..., Any]) -> None:
2423 self._dict = {}
2424 for k, v in kwargs.items():
2425 self[k] = v
2427 def __getitem__(self, key: str) -> Callable[..., Any]:
2428 return self._dict[key]
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
2439 def __iter__(self) -> Iterator[str]:
2440 return iter(self._dict)
2442 def __len__(self) -> int:
2443 return len(self._dict)
2445 def __contains__(self, key: object) -> bool:
2446 return key in self._dict
2448 def __call__(self, key: str, *args: object, **kwargs: object) -> Any:
2449 return self[key](*args, **kwargs)
2452def decode_text(text: str | bytes, initial: str = "utf-8") -> str:
2453 """
2454 Try to decode byte input as a string.
2456 Tries initial guess (UTF-8) then if that fails it
2457 uses charset_normalizer to try another guess before failing.
2459 Parameters
2460 ------------
2461 text : bytes
2462 Data that might be a string
2463 initial : str
2464 Initial guess for text encoding.
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
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
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")
2496def to_ascii(text: Any) -> str:
2497 """
2498 Force a string or other to ASCII text ignoring errors.
2500 Parameters
2501 -----------
2502 text : any
2503 Input to be converted to ASCII string
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)
2520def is_ccw(points: ArrayLike, return_all: bool = False):
2521 """
2522 Check if connected 2D points are counterclockwise.
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.
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)
2542 if len(points.shape) != 2 or points.shape[1] != 2:
2543 raise ValueError("only defined for `(n, 2)` points")
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
2552 if not return_all:
2553 return ccw
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 )
2560 return ccw, area, centroid
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.
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!
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
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_{}"
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
2627 # this should really never happen since we looped
2628 # through the full length of contains
2629 raise ValueError("Unable to establish unique name!")