Source code for trimesh.util

"""
"Grab bag" of utility functions.
"""

import abc
import base64
import collections
import copy
import json
import logging
import random
import shutil
import sys
import time
import uuid
import warnings
import zipfile

# for type checking
from collections.abc import Mapping
from io import BytesIO, StringIO

import numpy as np

from .iteration import chain

# use our wrapped types for wider version compatibility
from .typed import Union

# create a default logger
log = logging.getLogger("trimesh")

ABC = abc.ABC
now = time.time
which = shutil.which

# include constants here so we don't have to import
# a floating point threshold for 0.0
# we are setting it to 100x the resolution of a float64
# which works out to be 1e-13
TOL_ZERO = np.finfo(np.float64).resolution * 100
# how close to merge vertices
TOL_MERGE = 1e-8
# enable additional potentially slow checks
_STRICT = False

_IDENTITY = np.eye(4, dtype=np.float64)
_IDENTITY.flags["WRITEABLE"] = False


def has_module(name: str) -> bool:
    """
    Check to see if a module is installed by name without
    actually importing the module.

    Parameters
    ------------
    name : str
      The name of the module to check

    Returns
    ------------
    installed : bool
      True if module is installed
    """
    if sys.version_info >= (3, 10):
        # pkgutil was deprecated
        from importlib.util import find_spec
    else:
        # this should work on Python 2.7 and 3.4+
        from pkgutil import find_loader as find_spec

    return find_spec(name) is not None


[docs] def unitize(vectors, check_valid=False, threshold=None): """ Unitize a vector or an array or row-vectors. Parameters ------------ vectors : (n,m) or (j) float Vector or vectors to be unitized check_valid : bool If set, will return mask of nonzero vectors threshold : float Cutoff for a value to be considered zero. Returns --------- unit : (n,m) or (j) float Input vectors but unitized valid : (n,) bool or bool Mask of nonzero vectors returned if `check_valid` """ # make sure we have a numpy array vectors = np.asanyarray(vectors) # allow user to set zero threshold if threshold is None: threshold = TOL_ZERO if len(vectors.shape) == 2: # for (m, d) arrays take the per-row unit vector # using sqrt and avoiding exponents is slightly faster # also dot with ones is faser than .sum(axis=1) norm = np.sqrt(np.dot(vectors * vectors, [1.0] * vectors.shape[1])) # non-zero norms valid = norm > threshold # in-place reciprocal of nonzero norms norm[valid] **= -1 # multiply by reciprocal of norm unit = vectors * norm.reshape((-1, 1)) elif len(vectors.shape) == 1: # treat 1D arrays as a single vector norm = np.sqrt(np.dot(vectors, vectors)) valid = norm > threshold if valid: unit = vectors / norm else: unit = vectors.copy() else: raise ValueError("vectors must be (n, ) or (n, d)!") if check_valid: return unit[valid], valid return unit
def euclidean(a, b) -> float: """ DEPRECATED: use `np.linalg.norm(a - b)` instead of this. """ warnings.warn( "`trimesh.util.euclidean` is deprecated " + "and will be removed in January 2025. " + "replace with `np.linalg.norm(a - b)`", category=DeprecationWarning, stacklevel=2, ) a = np.asanyarray(a, dtype=np.float64) b = np.asanyarray(b, dtype=np.float64) return np.sqrt(((a - b) ** 2).sum()) def is_file(obj): """ Check if an object is file-like Parameters ------------ obj : object Any object type to be checked Returns ----------- is_file : bool True if object is a file """ return hasattr(obj, "read") or hasattr(obj, "write") def is_pathlib(obj): """ Check if the object is a `pathlib.Path` or subclass. Parameters ------------ obj : object Object to be checked Returns ------------ is_pathlib : bool Is the input object a pathlib path """ # check class name rather than a pathlib import name = obj.__class__.__name__ return hasattr(obj, "absolute") and name.endswith("Path") def is_string(obj) -> bool: """ DEPRECATED : this is not necessary since we dropped Python 2. Replace with `isinstance(obj, str)` """ warnings.warn( "`trimesh.util.is_string` is deprecated " + "and will be removed in January 2025. " + "replace with `isinstance(obj, str)`", category=DeprecationWarning, stacklevel=2, ) return isinstance(obj, str) def is_sequence(obj) -> bool: """ Check if an object is a sequence or not. Parameters ------------- obj : object Any object type to be checked Returns ------------- is_sequence : bool True if object is sequence """ seq = (not hasattr(obj, "strip") and hasattr(obj, "__getitem__")) or hasattr( obj, "__iter__" ) # check to make sure it is not a set, string, or dictionary seq = seq and all(not isinstance(obj, i) for i in (dict, set, str)) # PointCloud objects can look like an array but are not seq = seq and type(obj).__name__ not in ["PointCloud"] # numpy sometimes returns objects that are single float64 values # but sure look like sequences, so we check the shape if hasattr(obj, "shape"): seq = seq and obj.shape != () return seq def is_shape(obj, shape, allow_zeros: bool = False) -> bool: """ Compare the shape of a numpy.ndarray to a target shape, with any value less than zero being considered a wildcard Note that if a list-like object is passed that is not a numpy array, this function will not convert it and will return False. Parameters ------------ obj : np.ndarray Array to check the shape on shape : list or tuple Any negative term will be considered a wildcard Any tuple term will be evaluated as an OR allow_zeros: bool if False, zeros do not match negatives in shape Returns --------- shape_ok : bool True if shape of obj matches query shape Examples ------------------------ In [1]: a = np.random.random((100, 3)) In [2]: a.shape Out[2]: (100, 3) In [3]: trimesh.util.is_shape(a, (-1, 3)) Out[3]: True In [4]: trimesh.util.is_shape(a, (-1, 3, 5)) Out[4]: False In [5]: trimesh.util.is_shape(a, (100, -1)) Out[5]: True In [6]: trimesh.util.is_shape(a, (-1, (3, 4))) Out[6]: True In [7]: trimesh.util.is_shape(a, (-1, (4, 5))) Out[7]: False """ # if the obj.shape is different length than # the goal shape it means they have different number # of dimensions and thus the obj is not the query shape if not hasattr(obj, "shape") or len(obj.shape) != len(shape): return False # empty lists with any flexible dimensions match if len(obj) == 0 and -1 in shape: return True # loop through each integer of the two shapes # multiple values are sequences # wildcards are less than zero (i.e. -1) for i, target in zip(obj.shape, shape): # check if current field has multiple acceptable values if is_sequence(target): if i in target: # obj shape is in the accepted values continue else: return False # check if current field is a wildcard if target < 0: if i == 0 and not allow_zeros: # if a dimension is 0, we don't allow # that to match to a wildcard # it would have to be explicitly called out as 0 return False else: continue # since we have a single target and a single value, # if they are not equal we have an answer if target != i: return False # since none of the checks failed the obj.shape # matches the pattern return True def make_sequence(obj): """ Given an object, if it is a sequence return, otherwise add it to a length 1 sequence and return. Useful for wrapping functions which sometimes return single objects and other times return lists of objects. Parameters ------------- obj : object An object to be made a sequence Returns -------------- as_sequence : (n,) sequence Contains input value """ if is_sequence(obj): return list(obj) else: return [obj] def vector_hemisphere(vectors, return_sign=False): """ For a set of 3D vectors alter the sign so they are all in the upper hemisphere. If the vector lies on the plane all vectors with negative Y will be reversed. If the vector has a zero Z and Y value vectors with a negative X value will be reversed. Parameters ------------ vectors : (n, 3) float Input vectors return_sign : bool Return the sign mask or not Returns ---------- oriented: (n, 3) float Vectors with same magnitude as source but possibly reversed to ensure all vectors are in the same hemisphere. sign : (n,) float [OPTIONAL] sign of original vectors """ # vectors as numpy array vectors = np.asanyarray(vectors, dtype=np.float64) if is_shape(vectors, (-1, 2)): # 2D vector case # check the Y value and reverse vector # direction if negative. negative = vectors < -TOL_ZERO zero = np.logical_not(np.logical_or(negative, vectors > TOL_ZERO)) signs = np.ones(len(vectors), dtype=np.float64) # negative Y values are reversed signs[negative[:, 1]] = -1.0 # zero Y and negative X are reversed signs[np.logical_and(zero[:, 1], negative[:, 0])] = -1.0 elif is_shape(vectors, (-1, 3)): # 3D vector case negative = vectors < -TOL_ZERO zero = np.logical_not(np.logical_or(negative, vectors > TOL_ZERO)) # move all negative Z to positive # then for zero Z vectors, move all negative Y to positive # then for zero Y vectors, move all negative X to positive signs = np.ones(len(vectors), dtype=np.float64) # all vectors with negative Z values signs[negative[:, 2]] = -1.0 # all on-plane vectors with negative Y values signs[np.logical_and(zero[:, 2], negative[:, 1])] = -1.0 # all on-plane vectors with zero Y values # and negative X values signs[ np.logical_and(np.logical_and(zero[:, 2], zero[:, 1]), negative[:, 0]) ] = -1.0 else: raise ValueError("vectors must be (n, 3)!") # apply the signs to the vectors oriented = vectors * signs.reshape((-1, 1)) if return_sign: return oriented, signs return oriented def vector_to_spherical(cartesian): """ Convert a set of cartesian points to (n, 2) spherical unit vectors. Parameters ------------ cartesian : (n, 3) float Points in space Returns ------------ spherical : (n, 2) float Angles, in radians """ cartesian = np.asanyarray(cartesian, dtype=np.float64) if not is_shape(cartesian, (-1, 3)): raise ValueError("Cartesian points must be (n, 3)!") unit, valid = unitize(cartesian, check_valid=True) unit[np.abs(unit) < TOL_MERGE] = 0.0 x, y, z = unit.T spherical = np.zeros((len(cartesian), 2), dtype=np.float64) spherical[valid] = np.column_stack((np.arctan2(y, x), np.arccos(z))) return spherical def spherical_to_vector(spherical): """ Convert an array of `(n, 2)` spherical angles to `(n, 3)` unit vectors. Parameters ------------ spherical : (n , 2) float Angles, in radians Returns ----------- vectors : (n, 3) float Unit vectors """ spherical = np.asanyarray(spherical, dtype=np.float64) if not is_shape(spherical, (-1, 2)): raise ValueError("spherical coordinates must be (n, 2)!") theta, phi = spherical.T st, ct = np.sin(theta), np.cos(theta) sp, cp = np.sin(phi), np.cos(phi) return np.column_stack((ct * sp, st * sp, cp)) def pairwise(iterable): """ For an iterable, group values into pairs. Parameters ------------ iterable : (m, ) list A sequence of values Returns ----------- pairs: (n, 2) Pairs of sequential values Example ----------- In [1]: data Out[1]: [0, 1, 2, 3, 4, 5, 6] In [2]: list(trimesh.util.pairwise(data)) Out[2]: [(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6)] """ # looping through a giant numpy array would be dumb # so special case ndarrays and use numpy operations if isinstance(iterable, np.ndarray): iterable = iterable.reshape(-1) stacked = np.column_stack((iterable, iterable)) pairs = stacked.reshape(-1)[1:-1].reshape((-1, 2)) return pairs # if we have a normal iterable use itertools import itertools a, b = itertools.tee(iterable) # pop the first element of the second item next(b) return zip(a, b) try: # prefer the faster numpy version of multi_dot # only included in recent-ish version of numpy multi_dot = np.linalg.multi_dot except AttributeError: log.debug("np.linalg.multi_dot not available, using fallback") def multi_dot(arrays): """ Compute the dot product of two or more arrays in a single function call. In most versions of numpy this is included, this slower function is provided for backwards compatibility with ancient versions of numpy. """ arrays = np.asanyarray(arrays) result = arrays[0].copy() for i in arrays[1:]: result = np.dot(result, i) return result def diagonal_dot(a, b): """ Dot product by row of a and b. There are a lot of ways to do this though performance varies very widely. This method uses a dot product to sum the row and avoids function calls if at all possible. Comparing performance of some equivalent versions: ``` In [1]: import numpy as np; import trimesh In [2]: a = np.random.random((10000, 3)) In [3]: b = np.random.random((10000, 3)) In [4]: %timeit (a * b).sum(axis=1) 1000 loops, best of 3: 181 us per loop In [5]: %timeit np.einsum('ij,ij->i', a, b) 10000 loops, best of 3: 62.7 us per loop In [6]: %timeit np.diag(np.dot(a, b.T)) 1 loop, best of 3: 429 ms per loop In [7]: %timeit np.dot(a * b, np.ones(a.shape[1])) 10000 loops, best of 3: 61.3 us per loop In [8]: %timeit trimesh.util.diagonal_dot(a, b) 10000 loops, best of 3: 55.2 us per loop ``` Parameters ------------ a : (m, d) float First array b : (m, d) float Second array Returns ------------- result : (m,) float Dot product of each row """ # make sure `a` is numpy array # doing it for `a` will force the multiplication to # convert `b` if necessary and avoid function call otherwise a = np.asanyarray(a) # 3x faster than (a * b).sum(axis=1) # avoiding np.ones saves 5-10% sometimes return np.dot(a * b, [1.0] * a.shape[1]) def row_norm(data): """ Compute the norm per-row of a numpy array. This is identical to np.linalg.norm(data, axis=1) but roughly three times faster due to being less general. In [3]: %timeit trimesh.util.row_norm(a) 76.3 us +/- 651 ns per loop In [4]: %timeit np.linalg.norm(a, axis=1) 220 us +/- 5.41 us per loop Parameters ------------- data : (n, d) float Input 2D data to calculate per-row norm of Returns ------------- norm : (n,) float Norm of each row of input array """ return np.sqrt(np.dot(data**2, [1] * data.shape[1])) def stack_3D(points, return_2D=False): """ For a list of (n, 2) or (n, 3) points return them as (n, 3) 3D points, 2D points on the XY plane. Parameters ------------ points : (n, 2) or (n, 3) float Points in either 2D or 3D space return_2D : bool Were the original points 2D? Returns ---------- points : (n, 3) float Points in space is_2D : bool [OPTIONAL] if source points were (n, 2) """ points = np.asanyarray(points, dtype=np.float64) shape = points.shape if shape == (0,): is_2D = False elif len(shape) != 2: raise ValueError("Points must be 2D array!") elif shape[1] == 2: points = np.column_stack((points, np.zeros(len(points)))) is_2D = True elif shape[1] == 3: is_2D = False else: raise ValueError("Points must be (n, 2) or (n, 3)!") if return_2D: return points, is_2D return points def grid_arange(bounds, step): """ Return a grid from an (2,dimension) bounds with samples step distance apart. Parameters ------------ bounds: (2,dimension) list of [[min x, min y, etc], [max x, max y, etc]] step: float, or (dimension) floats, separation between points Returns --------- grid: (n, dimension), points inside the specified bounds """ bounds = np.asanyarray(bounds, dtype=np.float64) if len(bounds) != 2: raise ValueError("bounds must be (2, dimension!") # allow single float or per-dimension spacing step = np.asanyarray(step, dtype=np.float64) if step.shape == (): step = np.tile(step, bounds.shape[1]) grid_elements = [np.arange(*b, step=s) for b, s in zip(bounds.T, step)] grid = ( np.vstack(np.meshgrid(*grid_elements, indexing="ij")) .reshape(bounds.shape[1], -1) .T ) return grid def grid_linspace(bounds, count): """ Return a grid spaced inside a bounding box with edges spaced using np.linspace. Parameters ------------ bounds: (2,dimension) list of [[min x, min y, etc], [max x, max y, etc]] count: int, or (dimension,) int, number of samples per side Returns --------- grid: (n, dimension) float, points in the specified bounds """ bounds = np.asanyarray(bounds, dtype=np.float64) if len(bounds) != 2: raise ValueError("bounds must be (2, dimension!") count = np.asanyarray(count, dtype=np.int64) if count.shape == (): count = np.tile(count, bounds.shape[1]) grid_elements = [np.linspace(*b, num=c) for b, c in zip(bounds.T, count)] grid = ( np.vstack(np.meshgrid(*grid_elements, indexing="ij")) .reshape(bounds.shape[1], -1) .T ) return grid def multi_dict(pairs): """ Given a set of key value pairs, create a dictionary. If a key occurs multiple times, stack the values into an array. Can be called like the regular dict(pairs) constructor Parameters ------------ pairs: (n, 2) array of key, value pairs Returns ---------- result: dict, with all values stored (rather than last with regular dict) """ result = collections.defaultdict(list) for k, v in pairs: result[k].append(v) return result def tolist(data): """ Ensure that any arrays or dicts passed containing numpy arrays are properly converted to lists Parameters ------------- data : any Usually a dict with some numpy arrays as values Returns ---------- result : any JSON-serializable version of data """ result = json.loads(jsonify(data)) return result def is_binary_file(file_obj): """ Returns True if file has non-ASCII characters (> 0x7F, or 127) Should work in both Python 2 and 3 """ start = file_obj.tell() fbytes = file_obj.read(1024) file_obj.seek(start) is_str = isinstance(fbytes, str) for fbyte in fbytes: if is_str: code = ord(fbyte) else: code = fbyte if code > 127: return True return False def distance_to_end(file_obj): """ For an open file object how far is it to the end Parameters ------------ file_obj: open file-like object Returns ---------- distance: int, bytes to end of file """ position_current = file_obj.tell() file_obj.seek(0, 2) position_end = file_obj.tell() file_obj.seek(position_current) distance = position_end - position_current return distance def decimal_to_digits(decimal, min_digits=None) -> int: """ Return the number of digits to the first nonzero decimal. Parameters ----------- decimal: float min_digits: int, minimum number of digits to return Returns ----------- digits: int, number of digits to the first nonzero decimal """ digits = abs(int(np.log10(decimal))) if min_digits is not None: digits = np.clip(digits, min_digits, 20) return int(digits) def attach_to_log( level=logging.DEBUG, handler=None, loggers=None, colors=True, capture_warnings=True, blacklist=None, ): """ Attach a stream handler to all loggers. Parameters ------------ level : enum Logging level, like logging.INFO handler : None or logging.Handler Handler to attach loggers : None or (n,) logging.Logger If None, will try to attach to all available colors : bool If True try to use colorlog formatter blacklist : (n,) str Names of loggers NOT to attach to """ # default blacklist includes ipython debugging stuff if blacklist is None: blacklist = [ "TerminalIPythonApp", "PYREADLINE", "pyembree", "shapely", "matplotlib", "parso", ] # make sure we log warnings from the warnings module logging.captureWarnings(capture_warnings) # create a basic formatter formatter = logging.Formatter( "[%(asctime)s] %(levelname)-7s (%(filename)s:%(lineno)3s) %(message)s", "%Y-%m-%d %H:%M:%S", ) if colors: try: from colorlog import ColoredFormatter formatter = ColoredFormatter( ( "%(log_color)s%(levelname)-8s%(reset)s " + "%(filename)17s:%(lineno)-4s %(blue)4s%(message)s" ), datefmt=None, reset=True, log_colors={ "DEBUG": "cyan", "INFO": "green", "WARNING": "yellow", "ERROR": "red", "CRITICAL": "red", }, ) except ImportError: pass # if no handler was passed use a StreamHandler if handler is None: handler = logging.StreamHandler() # add the formatters and set the level handler.setFormatter(formatter) handler.setLevel(level) # if nothing passed use all available loggers if loggers is None: # de-duplicate loggers using a set loggers = set(logging.Logger.manager.loggerDict.values()) # add the warnings logging loggers.add(logging.getLogger("py.warnings")) # disable pyembree warnings logging.getLogger("pyembree").disabled = True # loop through all available loggers for logger in loggers: # skip loggers on the blacklist if logger.__class__.__name__ != "Logger" or any( logger.name.startswith(b) for b in blacklist ): continue logger.addHandler(handler) logger.setLevel(level) # set nicer numpy print options np.set_printoptions(precision=5, suppress=True) def stack_lines(indices): """ Stack a list of values that represent a polyline into individual line segments with duplicated consecutive values. Parameters ------------ indices : (m,) any List of items to be stacked Returns --------- stacked : (n, 2) any Stacked items Examples ---------- In [1]: trimesh.util.stack_lines([0, 1, 2]) Out[1]: array([[0, 1], [1, 2]]) In [2]: trimesh.util.stack_lines([0, 1, 2, 4, 5]) Out[2]: array([[0, 1], [1, 2], [2, 4], [4, 5]]) In [3]: trimesh.util.stack_lines([[0, 0], [1, 1], [2, 2], [3, 3]]) Out[3]: array([[0, 0], [1, 1], [1, 1], [2, 2], [2, 2], [3, 3]]) """ indices = np.asanyarray(indices) if len(indices) == 0: return np.array([]) elif is_sequence(indices[0]): shape = (-1, len(indices[0])) else: shape = (-1, 2) return np.column_stack((indices[:-1], indices[1:])).reshape(shape) def append_faces(vertices_seq, faces_seq): """ Given a sequence of zero-indexed faces and vertices combine them into a single array of faces and a single array of vertices. Parameters ----------- vertices_seq : (n, ) sequence of (m, d) float Multiple arrays of verticesvertex arrays faces_seq : (n, ) sequence of (p, j) int Zero indexed faces for matching vertices Returns ---------- vertices : (i, d) float Points in space faces : (j, 3) int Reference vertex indices """ # the length of each vertex array vertices_len = np.array([len(i) for i in vertices_seq]) # how much each group of faces needs to be offset face_offset = np.append(0, np.cumsum(vertices_len)[:-1]) new_faces = [] for offset, faces in zip(face_offset, faces_seq): if len(faces) == 0: continue # apply the index offset new_faces.append(faces + offset) # stack to clean (n, 3) float vertices = vstack_empty(vertices_seq) # stack to clean (n, 3) int faces = vstack_empty(new_faces) return vertices, faces def array_to_string(array, col_delim=" ", row_delim="\n", digits=8, value_format="{}"): """ Convert a 1 or 2D array into a string with a specified number of digits and delimiter. The reason this exists is that the basic numpy array to string conversions are surprisingly bad. Parameters ------------ array : (n,) or (n, d) float or int Data to be converted If shape is (n,) only column delimiter will be used col_delim : str What string should separate values in a column row_delim : str What string should separate values in a row digits : int How many digits should floating point numbers include value_format : str Format string for each value or sequence of values If multiple values per value_format it must divide into array evenly. Returns ---------- formatted : str String representation of original array """ # convert inputs to correct types array = np.asanyarray(array) digits = int(digits) row_delim = str(row_delim) col_delim = str(col_delim) value_format = str(value_format) # abort for non-flat arrays if len(array.shape) > 2: raise ValueError( "conversion only works on 1D/2D arrays not %s!", str(array.shape) ) # abort for structured arrays if array.dtype.names is not None: raise ValueError("array is structured, use structured_array_to_string instead") # allow a value to be repeated in a value format repeats = value_format.count("{") if array.dtype.kind in ["i", "u"]: # integer types don't need a specified precision format_str = value_format + col_delim elif array.dtype.kind == "f": # add the digits formatting to floats format_str = value_format.replace("{}", "{:." + str(digits) + "f}") + col_delim else: raise ValueError("dtype %s not convertible!", array.dtype.name) # length of extra delimiters at the end end_junk = len(col_delim) # if we have a 2D array add a row delimiter if len(array.shape) == 2: format_str *= array.shape[1] # cut off the last column delimiter and add a row delimiter format_str = format_str[: -len(col_delim)] + row_delim end_junk = len(row_delim) # expand format string to whole array format_str *= len(array) # if an array is repeated in the value format # do the shaping here so we don't need to specify indexes shaped = np.tile(array.reshape((-1, 1)), (1, repeats)).reshape(-1) # run the format operation and remove the extra delimiters formatted = format_str.format(*shaped)[:-end_junk] return formatted def structured_array_to_string( array, col_delim=" ", row_delim="\n", digits=8, value_format="{}" ): """ Convert an unstructured array into a string with a specified number of digits and delimiter. The reason thisexists is that the basic numpy array to string conversionsare surprisingly bad. Parameters ------------ array : (n,) or (n, d) float or int Data to be converted If shape is (n,) only column delimiter will be used col_delim : str What string should separate values in a column row_delim : str What string should separate values in a row digits : int How many digits should floating point numbers include value_format : str Format string for each value or sequence of values If multiple values per value_format it must divide into array evenly. Returns ---------- formatted : str String representation of original array """ # convert inputs to correct types array = np.asanyarray(array) digits = int(digits) row_delim = str(row_delim) col_delim = str(col_delim) value_format = str(value_format) # abort for non-flat arrays if len(array.shape) > 1: raise ValueError( "conversion only works on 1D/2D arrays not %s!", str(array.shape) ) # abort for unstructured arrays if array.dtype.names is None: raise ValueError("array is not structured, use array_to_string instead") # do not allow a value to be repeated in a value format if value_format.count("{") > 1: raise ValueError( "value_format %s is invalid, repeating unstructured array " + "values is unsupported", value_format, ) format_str = "" for name in array.dtype.names: kind = array[name].dtype.kind element_row_length = array[name].shape[1] if len(array[name].shape) == 2 else 1 if kind in ["i", "u"]: # integer types need a no-decimal formatting element_format_str = value_format.replace("{}", "{:0.0f}") + col_delim elif kind == "f": # add the digits formatting to floats element_format_str = ( value_format.replace("{}", "{:." + str(digits) + "f}") + col_delim ) else: raise ValueError("dtype %s not convertible!", array.dtype) format_str += element_row_length * element_format_str # length of extra delimiters at the end format_str = format_str[: -len(col_delim)] + row_delim # expand format string to whole array format_str *= len(array) # loop through flat fields and flatten to single array count = len(array) # will upgrade everything to a float flattened = np.hstack( [array[k].reshape((count, -1)) for k in array.dtype.names] ).reshape(-1) # run the format operation and remove the extra delimiters formatted = format_str.format(*flattened)[: -len(row_delim)] return formatted def array_to_encoded(array, dtype=None, encoding="base64"): """ Export a numpy array to a compact serializable dictionary. Parameters ------------ array : array Any numpy array dtype : str or None Optional dtype to encode array encoding : str 'base64' or 'binary' Returns --------- encoded : dict Has keys: 'dtype': str, of dtype 'shape': tuple of shape 'base64': str, base64 encoded string """ array = np.asanyarray(array) shape = array.shape # ravel also forces contiguous flat = np.ravel(array) if dtype is None: dtype = array.dtype encoded = {"dtype": np.dtype(dtype).str, "shape": shape} if encoding in ["base64", "dict64"]: packed = base64.b64encode(flat.astype(dtype).tobytes()) if hasattr(packed, "decode"): packed = packed.decode("utf-8") encoded["base64"] = packed elif encoding == "binary": encoded["binary"] = array.tobytes(order="C") else: raise ValueError(f"encoding {encoding} is not available!") return encoded def decode_keys(store, encoding="utf-8"): """ If a dictionary has keys that are bytes decode them to a str. Parameters ------------ store : dict Dictionary with data Returns --------- result : dict Values are untouched but keys that were bytes are converted to ASCII strings. Example ----------- In [1]: d Out[1]: {1020: 'nah', b'hi': 'stuff'} In [2]: trimesh.util.decode_keys(d) Out[2]: {1020: 'nah', 'hi': 'stuff'} """ keys = store.keys() for key in keys: if hasattr(key, "decode"): decoded = key.decode(encoding) if key != decoded: store[key.decode(encoding)] = store[key] store.pop(key) return store def comment_strip(text, starts_with="#", new_line="\n"): """ Strip comments from a text block. Parameters ----------- text : str Text to remove comments from starts_with : str Character or substring that starts a comment new_line : str Character or substring that ends a comment Returns ----------- stripped : str Text with comments stripped """ # if not contained exit immediately if starts_with not in text: return text # start by splitting into chunks by the comment indicator split = (text + new_line).split(starts_with) # special case files that start with a comment if text.startswith(starts_with): lead = "" else: lead = split[0] # take each comment up until the newline removed = [i.split(new_line, 1) for i in split] # add the leading string back on result = ( lead + new_line + new_line.join(i[1] for i in removed if len(i) > 1 and len(i[1]) > 0) ) # strip leading and trailing whitespace result = result.strip() return result def encoded_to_array(encoded): """ Turn a dictionary with base64 encoded strings back into a numpy array. Parameters ------------ encoded : dict Has keys: dtype: string of dtype shape: int tuple of shape base64: base64 encoded string of flat array binary: decode result coming from numpy.tobytes Returns ---------- array: numpy array """ if not isinstance(encoded, dict): if is_sequence(encoded): as_array = np.asanyarray(encoded) return as_array else: raise ValueError("Unable to extract numpy array from input") encoded = decode_keys(encoded) dtype = np.dtype(encoded["dtype"]) if "base64" in encoded: array = np.frombuffer(base64.b64decode(encoded["base64"]), dtype) elif "binary" in encoded: array = np.frombuffer(encoded["binary"], dtype=dtype) if "shape" in encoded: array = array.reshape(encoded["shape"]) return array def is_instance_named(obj, name): """ Given an object, if it is a member of the class 'name', or a subclass of 'name', return True. Parameters ------------ obj : instance Some object of some class name: str The name of the class we want to check for Returns --------- is_instance : bool Whether the object is a member of the named class """ try: if isinstance(name, list): return any(is_instance_named(obj, i) for i in name) else: type_named(obj, name) return True except ValueError: return False def type_bases(obj, depth=4): """ Return the bases of the object passed. """ bases = collections.deque([list(obj.__class__.__bases__)]) for i in range(depth): bases.append([i.__base__ for i in bases[-1] if i is not None]) try: bases = np.hstack(bases) except IndexError: bases = [] return [i for i in bases if hasattr(i, "__name__")] def type_named(obj, name): """ Similar to the type() builtin, but looks in class bases for named instance. Parameters ------------ obj : any Object to look for class of name : str Nnme of class Returns ---------- class : Optional[Callable] Camed class, or None """ # if obj is a member of the named class, return True name = str(name) if obj.__class__.__name__ == name: return obj.__class__ for base in type_bases(obj): if base.__name__ == name: return base raise ValueError("Unable to extract class of name " + name) def concatenate( a, b=None ) -> Union["trimesh.Trimesh", "trimesh.path.Path2D", "trimesh.path.Path3D"]: # noqa: F821 """ Concatenate two or more meshes. Parameters ------------ a : trimesh.Trimesh Mesh or list of meshes to be concatenated object, or list of such b : trimesh.Trimesh Mesh or list of meshes to be concatenated Returns ---------- result Concatenated mesh """ dump = [] for i in chain(a, b): if is_instance_named(i, "Scene"): # get every mesh in the final frame. dump.extend(i.dump()) else: # just append to our flat list dump.append(i) if len(dump) == 1: # if there is only one geometry just return the first return dump[0].copy() elif len(dump) == 0: # if there are no meshes return an empty mesh from .base import Trimesh return Trimesh() is_mesh = [f for f in dump if is_instance_named(f, "Trimesh")] is_path = [f for f in dump if is_instance_named(f, "Path")] # if we have more if len(is_path) > len(is_mesh): from .path.util import concatenate as concatenate_path return concatenate_path(is_path) if len(is_mesh) == 0: return [] # extract the trimesh type to avoid a circular import # and assert that all inputs are Trimesh objects trimesh_type = type_named(is_mesh[0], "Trimesh") # append faces and vertices of meshes vertices, faces = append_faces( [m.vertices.copy() for m in is_mesh], [m.faces.copy() for m in is_mesh] ) # save face normals if already calculated face_normals = None if any("face_normals" in m._cache for m in is_mesh): face_normals = vstack_empty([m.face_normals for m in is_mesh]) assert face_normals.shape == faces.shape # save vertex normals if any mesh has them vertex_normals = None if any("vertex_normals" in m._cache for m in is_mesh): vertex_normals = vstack_empty([m.vertex_normals for m in is_mesh]) assert vertex_normals.shape == vertices.shape try: # concatenate visuals visual = is_mesh[0].visual.concatenate([m.visual for m in is_mesh[1:]]) except BaseException as E: log.debug(f"failed to combine visuals {_STRICT}", exc_info=True) visual = None if _STRICT: raise E # create the mesh object return trimesh_type( vertices=vertices, faces=faces, face_normals=face_normals, vertex_normals=vertex_normals, visual=visual, process=False, ) def submesh( mesh, faces_sequence, repair=True, only_watertight=False, min_faces=None, append=False ): """ Return a subset of a mesh. Parameters ------------ mesh : Trimesh Source mesh to take geometry from faces_sequence : sequence (p,) int Indexes of mesh.faces repair : bool Try to make submeshes watertight only_watertight : bool Only return submeshes which are watertight append : bool Return a single mesh which has the faces appended, if this flag is set, only_watertight is ignored Returns --------- if append : Trimesh object else list of Trimesh objects """ # evaluate generators so we can escape early faces_sequence = list(faces_sequence) if len(faces_sequence) == 0: return [] # avoid nuking the cache on the original mesh original_faces = mesh.faces.view(np.ndarray) original_vertices = mesh.vertices.view(np.ndarray) faces = [] vertices = [] normals = [] visuals = [] # for reindexing faces mask = np.arange(len(original_vertices)) for index in faces_sequence: # sanitize indices in case they are coming in as a set or tuple index = np.asanyarray(index) if len(index) == 0: # regardless of type empty arrays are useless continue if index.dtype.kind == "b": # if passed a bool with no true continue if not index.any(): continue # if fewer faces than minimum if min_faces is not None and index.sum() < min_faces: continue elif min_faces is not None and len(index) < min_faces: continue current = original_faces[index] unique = np.unique(current.reshape(-1)) # redefine face indices from zero mask[unique] = np.arange(len(unique)) normals.append(mesh.face_normals[index]) faces.append(mask[current]) vertices.append(original_vertices[unique]) try: visuals.append(mesh.visual.face_subset(index)) except BaseException: visuals = None if len(vertices) == 0: return np.array([]) # we use type(mesh) rather than importing Trimesh from base # to avoid a circular import trimesh_type = type_named(mesh, "Trimesh") if append: visual = None try: visuals = np.array(visuals) visual = visuals[0].concatenate(visuals[1:]) except BaseException: pass # re-index faces and stack vertices, faces = append_faces(vertices, faces) appended = trimesh_type( vertices=vertices, faces=faces, face_normals=np.vstack(normals), visual=visual, process=False, ) return appended if visuals is None: visuals = [None] * len(vertices) # generate a list of Trimesh objects result = [ trimesh_type( vertices=v, faces=f, face_normals=n, visual=c, metadata=copy.deepcopy(mesh.metadata), process=False, ) for v, f, n, c in zip(vertices, faces, normals, visuals) ] if only_watertight or repair: # fill_holes will attempt a repair and returns the # watertight status at the end of the repair attempt watertight = [i.fill_holes() and len(i.faces) >= 4 for i in result] if only_watertight: # remove unrepairable meshes return [i for i, w in zip(result, watertight) if w] return result def zero_pad(data, count, right=True): """ Parameters ------------ data : (n,) 1D array count : int Minimum length of result array Returns --------- padded : (m,) 1D array where m >= count """ if len(data) == 0: return np.zeros(count) elif len(data) < count: padded = np.zeros(count) if right: padded[-len(data) :] = data else: padded[: len(data)] = data return padded else: return np.asanyarray(data) def jsonify(obj, **kwargs): """ A version of json.dumps that can handle numpy arrays by creating a custom encoder for numpy dtypes. Parameters -------------- obj : list, dict A JSON-serializable blob kwargs : dict Passed to json.dumps Returns -------------- dumped : str JSON dump of obj """ class EdgeEncoder(json.JSONEncoder): def default(self, obj): # will work for numpy.ndarrays # as well as their int64/etc objects if hasattr(obj, "tolist"): return obj.tolist() elif hasattr(obj, "timestamp"): return obj.timestamp() return json.JSONEncoder.default(self, obj) # run the dumps using our encoder return json.dumps(obj, cls=EdgeEncoder, **kwargs) def convert_like(item, like): """ Convert an item to have the dtype of another item Parameters ------------ item : any Item to be converted like : any Object with target dtype If None, item is returned unmodified Returns ---------- result: item, but in dtype of like """ # if it's a numpy array if isinstance(like, np.ndarray): return np.asanyarray(item, dtype=like.dtype) # if it's already the desired type just return it if isinstance(item, like.__class__) or like is None: return item # if it's an array with one item return it if is_sequence(item) and len(item) == 1 and isinstance(item[0], like.__class__): return item[0] if ( isinstance(item, str) and like.__class__.__name__ == "Polygon" and item.startswith("POLYGON") ): # break our rule on imports but only a little bit # the import was a WKT serialized polygon from shapely import wkt return wkt.loads(item) # otherwise just run the conversion item = like.__class__(item) return item def bounds_tree(bounds): """ Given a set of axis aligned bounds create an r-tree for broad-phase collision detection. Parameters ------------ bounds : (n, 2D) or (n, 2, D) float Non-interleaved bounds where D=dimension E.G a 2D bounds tree: [(minx, miny, maxx, maxy), ...] Returns --------- tree : Rtree Tree containing bounds by index """ import rtree # make sure we've copied bounds bounds = np.array(bounds, dtype=np.float64, copy=True) if len(bounds.shape) == 3: # should be min-max per bound if bounds.shape[1] != 2: raise ValueError("bounds not (n, 2, dimension)!") # reshape to one-row-per-hyperrectangle bounds = bounds.reshape((len(bounds), -1)) elif len(bounds.shape) != 2 or bounds.size == 0: raise ValueError("Bounds must be (n, dimension * 2)!") # check to make sure we have correct shape dimension = bounds.shape[1] if (dimension % 2) != 0: raise ValueError("Bounds must be (n,dimension*2)!") dimension = int(dimension / 2) properties = rtree.index.Property(dimension=dimension) # stream load was verified working on import above return rtree.index.Index( zip(np.arange(len(bounds)), bounds, [None] * len(bounds)), properties=properties ) def wrap_as_stream(item): """ Wrap a string or bytes object as a file object. Parameters ------------ item: str or bytes Item to be wrapped Returns --------- wrapped : file-like object Contains data from item """ if isinstance(item, str): return StringIO(item) elif isinstance(item, bytes): return BytesIO(item) raise ValueError(f"{type(item).__name__} is not wrappable!") def sigfig_round(values, sigfig=1): """ Round a single value to a specified number of significant figures. Parameters ------------ values : float Value to be rounded sigfig : int Number of significant figures to reduce to Returns ---------- rounded : float Value rounded to the specified number of significant figures Examples ---------- In [1]: trimesh.util.round_sigfig(-232453.00014045456, 1) Out[1]: -200000.0 In [2]: trimesh.util.round_sigfig(.00014045456, 1) Out[2]: 0.0001 In [3]: trimesh.util.round_sigfig(.00014045456, 4) Out[3]: 0.0001405 """ as_int, multiplier = sigfig_int(values, sigfig) rounded = as_int * (10**multiplier) return rounded def sigfig_int(values, sigfig): """ Convert a set of floating point values into integers with a specified number of significant figures and an exponent. Parameters ------------ values : (n,) float or int Array of values sigfig : (n,) int Number of significant figures to keep Returns ------------ as_int : (n,) int Every value[i] has sigfig[i] digits multiplier : (n, int) Exponent, so as_int * 10 ** multiplier is the same order of magnitude as the input """ values = np.asanyarray(values).reshape(-1) sigfig = np.asanyarray(sigfig, dtype=np.int64).reshape(-1) if sigfig.shape != values.shape: raise ValueError("sigfig must match identifier") exponent = np.zeros(len(values)) nonzero = np.abs(values) > TOL_ZERO exponent[nonzero] = np.floor(np.log10(np.abs(values[nonzero]))) multiplier = exponent - sigfig + 1 as_int = (values / (10**multiplier)).round().astype(np.int64) return as_int, multiplier def decompress(file_obj, file_type): """ Given an open file object and a file type, return all components of the archive as open file objects in a dict. Parameters ------------ file_obj : file-like Containing compressed data file_type : str File extension, 'zip', 'tar.gz', etc Returns --------- decompressed : dict Data from archive in format {file name : file-like} """ file_type = str(file_type).lower() if isinstance(file_obj, bytes): file_obj = wrap_as_stream(file_obj) if file_type.endswith("zip"): archive = zipfile.ZipFile(file_obj) return {name: wrap_as_stream(archive.read(name)) for name in archive.namelist()} if file_type.endswith("bz2"): import bz2 return {file_obj.name[:-4]: wrap_as_stream(bz2.open(file_obj, mode="r").read())} if "tar" in file_type[-6:]: import tarfile archive = tarfile.open(fileobj=file_obj, mode="r") return {name: archive.extractfile(name) for name in archive.getnames()} raise ValueError("Unsupported type passed!") def compress(info, **kwargs): """ Compress data stored in a dict. Parameters ----------- info : dict Data to compress in form: {file name in archive: bytes or file-like object} kwargs : dict Passed to zipfile.ZipFile Returns ----------- compressed : bytes Compressed file data """ file_obj = BytesIO() with zipfile.ZipFile( file_obj, mode="w", compression=zipfile.ZIP_DEFLATED, **kwargs ) as zipper: for name, data in info.items(): if hasattr(data, "read"): # if we were passed a file object, read it data = data.read() zipper.writestr(name, data) file_obj.seek(0) compressed = file_obj.read() return compressed def split_extension(file_name, special=None): """ Find the file extension of a file name, including support for special case multipart file extensions (like .tar.gz) Parameters ------------ file_name : str File name special : list of str Multipart extensions eg: ['tar.bz2', 'tar.gz'] Returns ---------- extension : str Last characters after a period, or a value from 'special' """ file_name = str(file_name) if special is None: special = ["tar.bz2", "tar.gz"] if file_name.endswith(tuple(special)): for end in special: if file_name.endswith(end): return end return file_name.split(".")[-1] def triangle_strips_to_faces(strips): """ Convert a sequence of triangle strips to (n, 3) faces. Processes all strips at once using np.concatenate and is significantly faster than loop-based methods. From the OpenGL programming guide describing a single triangle strip [v0, v1, v2, v3, v4]: Draws a series of triangles (three-sided polygons) using vertices v0, v1, v2, then v2, v1, v3 (note the order), then v2, v3, v4, and so on. The ordering is to ensure that the triangles are all drawn with the same orientation so that the strip can correctly form part of a surface. Parameters ------------ strips: (n,) list of (m,) int Vertex indices Returns ------------ faces : (m, 3) int Vertex indices representing triangles """ # save the length of each list in the list of lists lengths = np.array([len(i) for i in strips], dtype=np.int64) # looping through a list of lists is extremely slow # combine all the sequences into a blob we can manipulate blob = np.concatenate(strips, dtype=np.int64) # slice the blob into rough triangles tri = np.array([blob[:-2], blob[1:-1], blob[2:]], dtype=np.int64).T # if we only have one strip we can do a *lot* less work # as we keep every triangle and flip every other one if len(strips) == 1: # flip in-place every other triangle tri[1::2] = np.fliplr(tri[1::2]) return tri # remove the triangles which were implicit but not actually there # because we combined everything into one big array for speed length_index = np.cumsum(lengths)[:-1] keep = np.ones(len(tri), dtype=bool) keep[length_index - 2] = False keep[length_index - 1] = False tri = tri[keep] # flip every other triangle so they generate correct normals/winding length_index = np.append(0, np.cumsum(lengths - 2)) flip = np.zeros(length_index[-1], dtype=bool) for i in range(len(length_index) - 1): flip[length_index[i] + 1 : length_index[i + 1]][::2] = True tri[flip] = np.fliplr(tri[flip]) return tri def triangle_fans_to_faces(fans): """ Convert fans of m + 2 vertex indices in fan format to m triangles Parameters ---------- fans: (n,) list of (m + 2,) int Vertex indices Returns ------- faces: (m, 3) int Vertex indices representing triangles """ faces = [ np.transpose([fan[0] * np.ones(len(fan) - 2, dtype=int), fan[1:-1], fan[2:]]) for fan in fans ] return np.concatenate(faces, axis=1) def vstack_empty(tup): """ A thin wrapper for numpy.vstack that ignores empty lists. Parameters ------------ tup : tuple or list of arrays With the same number of columns Returns ------------ stacked : (n, d) array With same number of columns as constituent arrays. """ # filter out empty arrays stackable = [i for i in tup if len(i) > 0] # if we only have one array just return it if len(stackable) == 1: return np.asanyarray(stackable[0]) # if we have nothing return an empty numpy array elif len(stackable) == 0: return np.array([]) # otherwise just use vstack as normal return np.vstack(stackable) def write_encoded(file_obj, stuff, encoding="utf-8"): """ If a file is open in binary mode and a string is passed, encode and write. If a file is open in text mode and bytes are passed decode bytes to str and write. Assumes binary mode if file_obj does not have a 'mode' attribute (e.g. io.BufferedRandom). Parameters ----------- file_obj : file object With 'write' and 'mode' stuff : str or bytes Stuff to be written encoding : str Encoding of text """ binary_file = "b" in getattr(file_obj, "mode", "b") string_stuff = isinstance(stuff, str) binary_stuff = isinstance(stuff, bytes) if binary_file and string_stuff: file_obj.write(stuff.encode(encoding)) elif not binary_file and binary_stuff: file_obj.write(stuff.decode(encoding)) else: file_obj.write(stuff) file_obj.flush() return stuff def unique_id(length=12): """ Generate a random alphaNumber unique identifier using UUID logic. Parameters ------------ length : int Length of desired identifier Returns ------------ unique : str Unique alphaNumber identifier """ return uuid.UUID(int=random.getrandbits(128), version=4).hex[:length] def generate_basis(z, epsilon=1e-12): """ Generate an arbitrary basis (also known as a coordinate frame) from a given z-axis vector. Parameters ------------ z : (3,) float A vector along the positive z-axis. epsilon : float Numbers smaller than this considered zero. Returns --------- x : (3,) float Vector along x axis. y : (3,) float Vector along y axis. z : (3,) float Vector along z axis. """ # get a copy of input vector z = np.array(z, dtype=np.float64, copy=True) # must be a 3D vector if z.shape != (3,): raise ValueError("z must be (3,) float!") z_norm = np.linalg.norm(z) if z_norm < epsilon: return np.eye(3) # normalize vector in-place z /= z_norm # X as arbitrary perpendicular vector x = np.array([-z[1], z[0], 0.0]) # avoid degenerate case x_norm = np.linalg.norm(x) if x_norm < epsilon: # this means that # so a perpendicular X is just X x = np.array([-z[2], z[1], 0.0]) x /= np.linalg.norm(x) else: # otherwise normalize X in-place x /= x_norm # get perpendicular Y with cross product y = np.cross(z, x) # append result values into (3, 3) vector result = np.array([x, y, z], dtype=np.float64) if _STRICT: # run checks to make sure axis are perpendicular assert np.abs(np.dot(x, z)) < 1e-8 assert np.abs(np.dot(y, z)) < 1e-8 assert np.abs(np.dot(x, y)) < 1e-8 # all vectors should be unit vector assert np.allclose(np.linalg.norm(result, axis=1), 1.0) return result def isclose(a, b, atol: float = 1e-8): """ A replacement for np.isclose that does fewer checks and validation and as a result is roughly 4x faster. Note that this is used in tight loops, and as such a and b MUST be np.ndarray, not list or "array-like" Parameters ------------ a : np.ndarray To be compared b : np.ndarray To be compared atol : float Acceptable distance between `a` and `b` to be "close" Returns ----------- close : np.ndarray, bool Per-element closeness """ diff = a - b return np.logical_and(diff > -atol, diff < atol) def allclose(a, b, atol: float = 1e-8): """ A replacement for np.allclose that does few checks and validation and as a result is faster. Parameters ------------ a : np.ndarray To be compared b : np.ndarray To be compared atol : float Acceptable distance between `a` and `b` to be "close" Returns ----------- bool indicating if all elements are within `atol`. """ # return float(np.ptp(a - b)) < atol class FunctionRegistry(Mapping): """ Non-overwritable mapping of string keys to functions. This allows external packages to register additional implementations of common functionality without risk of breaking implementations provided by trimesh. See trimesh.voxel.morphology for example usage. """ def __init__(self, **kwargs): self._dict = {} for k, v in kwargs.items(): self[k] = v def __getitem__(self, key): return self._dict[key] def __setitem__(self, key, value): if not isinstance(key, str): raise ValueError(f"key must be a string, got {key!s}") if key in self: raise KeyError(f"Cannot set new value to existing key {key}") if not callable(value): raise ValueError("Cannot set value which is not callable.") self._dict[key] = value def __iter__(self): return iter(self._dict) def __len__(self): return len(self._dict) def __contains__(self, key): return key in self._dict def __call__(self, key, *args, **kwargs): return self[key](*args, **kwargs) def decode_text(text, initial="utf-8"): """ Try to decode byte input as a string. Tries initial guess (UTF-8) then if that fails it uses chardet to try another guess before failing. Parameters ------------ text : bytes Data that might be a string initial : str Initial guess for text encoding. Returns ------------ decoded : str Data as a string """ # if not bytes just return input if not hasattr(text, "decode"): return text try: # initially guess file is UTF-8 or specified encoding text = text.decode(initial) except UnicodeDecodeError: # detect different file encodings import chardet # try to detect the encoding of the file # only look at the first 1000 characters otherwise # for big files chardet looks at everything and is slow detect = chardet.detect(text[:1000]) # warn on files that aren't UTF-8 log.debug( "Data not {}! Trying {} (confidence {})".format( initial, detect["encoding"], detect["confidence"] ) ) # try to decode again, unwrap in try text = text.decode(detect["encoding"], errors="ignore") return text def to_ascii(text): """ Force a string or other to ASCII text ignoring errors. Parameters ----------- text : any Input to be converted to ASCII string Returns ----------- ascii : str Input as an ASCII string """ if hasattr(text, "encode"): # case for existing strings return text.encode("ascii", errors="ignore").decode("ascii") elif hasattr(text, "decode"): # case for bytes return text.decode("ascii", errors="ignore") # otherwise just wrap as a string return str(text) def is_ccw(points, return_all=False): """ Check if connected 2D points are counterclockwise. Parameters ----------- points : (n, 2) float Connected points on a plane return_all : bool Return polygon area and centroid or just counter-clockwise. Returns ---------- ccw : bool True if points are counter-clockwise area : float Only returned if `return_centroid` centroid : (2,) float Centroid of the polygon. """ points = np.array(points, dtype=np.float64) if len(points.shape) != 2 or points.shape[1] != 2: raise ValueError("only defined for `(n, 2)` points") # the "shoelace formula" product = np.subtract(*(points[:-1, [1, 0]] * points[1:]).T) # the area of the polygon area = product.sum() / 2.0 # check the sign of the area ccw = area < 0.0 if not return_all: return ccw # the centroid of the polygon uses the same formula centroid = ((points[:-1] + points[1:]) * product.reshape((-1, 1))).sum(axis=0) / ( 6.0 * area ) return ccw, area, centroid def unique_name(start, contains, counts=None): """ Deterministically generate a unique name not contained in a dict, set or other grouping with `__includes__` defined. Will create names of the form "start_10" and increment accordingly. Parameters ----------- start : str Initial guess for name. contains : dict, set, or list Bundle of existing names we can *not* use. counts : None or dict Maps name starts encountered before to increments in order to speed up finding a unique name as otherwise it potentially has to iterate through all of contains. Should map to "how many times has this `start` been attempted, i.e. `counts[start]: int`. Note that this *will be mutated* in-place by this function! Returns --------- unique : str A name that is not contained in `contains` """ # exit early if name is not in bundle if start is not None and len(start) > 0 and start not in contains: return start # start checking with zero index unless found if counts is None: increment = 0 else: increment = counts.get(start, 0) if start is not None and len(start) > 0: formatter = start + "_{}" # split by our delimiter once split = start.rsplit("_", 1) if len(split) == 2 and increment == 0: try: # start incrementing from the existing # trailing value # if it is not an integer this will fail increment = int(split[1]) # include the first split value formatter = split[0] + "_{}" except BaseException: pass else: formatter = "geometry_{}" # if contains is empty we will only need to check once for i in range(increment + 1, 2 + increment + len(contains)): check = formatter.format(i) if check not in contains: if counts is not None: counts[start] = i return check # this should really never happen since we looped # through the full length of contains raise ValueError("Unable to establish unique name!")