forked from s_ranjbar/city_retrofit
2314 lines
61 KiB
Python
2314 lines
61 KiB
Python
"""
|
|
util.py
|
|
-----------
|
|
|
|
Standalone functions which require only imports from numpy and the
|
|
standard library.
|
|
|
|
Other libraries may be imported must be wrapped in try/except blocks
|
|
or imported inside of a function
|
|
"""
|
|
|
|
import numpy as np
|
|
|
|
import abc
|
|
import sys
|
|
import copy
|
|
import json
|
|
import base64
|
|
import shutil
|
|
import logging
|
|
import hashlib
|
|
import zipfile
|
|
import tempfile
|
|
import collections
|
|
|
|
if sys.version_info >= (3, 4):
|
|
# for newer version of python
|
|
ABC = abc.ABC
|
|
else:
|
|
# an abstract base class that works on older versions
|
|
ABC = abc.ABCMeta('ABC', (), {})
|
|
|
|
# a flag we can check elsewhere for Python 3
|
|
PY3 = sys.version_info.major >= 3
|
|
if PY3:
|
|
# for type checking
|
|
basestring = str
|
|
# Python 3
|
|
from io import BytesIO, StringIO
|
|
# will be the highest granularity clock available
|
|
from time import perf_counter as now
|
|
else:
|
|
# Python 2
|
|
from StringIO import StringIO
|
|
# monkey patch StringIO so `with` statements work
|
|
StringIO.__enter__ = lambda a: a
|
|
StringIO.__exit__ = lambda a, b, c, d: a.close()
|
|
BytesIO = StringIO
|
|
# perf_counter not available on python 2
|
|
from time import time as now
|
|
|
|
|
|
try:
|
|
from collections.abc import Mapping
|
|
except ImportError:
|
|
from collections import Mapping
|
|
|
|
# create a default logger
|
|
log = logging.getLogger('trimesh')
|
|
|
|
# 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
|
|
|
|
|
|
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):
|
|
"""
|
|
Euclidean distance between vectors a and b.
|
|
|
|
Parameters
|
|
------------
|
|
a : (n,) float
|
|
First vector
|
|
b : (n,) float
|
|
Second vector
|
|
|
|
Returns
|
|
------------
|
|
distance : float
|
|
Euclidean distance between A and B
|
|
"""
|
|
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):
|
|
"""
|
|
Check if an object is a string.
|
|
|
|
Parameters
|
|
------------
|
|
obj : object
|
|
Any object type to be checked
|
|
|
|
Returns
|
|
------------
|
|
is_string : bool
|
|
True if obj is a string
|
|
"""
|
|
return isinstance(obj, basestring)
|
|
|
|
|
|
def is_none(obj):
|
|
"""
|
|
Check to see if an object is None or not.
|
|
|
|
Handles the case of np.array(None) as well.
|
|
|
|
Parameters
|
|
-------------
|
|
obj : object
|
|
Any object type to be checked
|
|
|
|
Returns
|
|
-------------
|
|
is_none : bool
|
|
True if obj is None or numpy None-like
|
|
"""
|
|
if obj is None:
|
|
return True
|
|
if (is_sequence(obj) and
|
|
len(obj) == 1 and
|
|
obj[0] is None):
|
|
return True
|
|
return False
|
|
|
|
|
|
def is_sequence(obj):
|
|
"""
|
|
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,
|
|
basestring))
|
|
|
|
# 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=False):
|
|
"""
|
|
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 np.array(list(obj))
|
|
else:
|
|
return np.array([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 a set of (n, 2) spherical vectors to (n, 3) 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)
|
|
vectors = np.column_stack((ct * sp,
|
|
st * sp,
|
|
cp))
|
|
return vectors
|
|
|
|
|
|
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.warning('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 len(shape) != 2:
|
|
raise ValueError('Points must be 2D array!')
|
|
|
|
if 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.int)
|
|
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):
|
|
"""
|
|
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 digits
|
|
|
|
|
|
def hash_file(file_obj,
|
|
hash_function=hashlib.md5):
|
|
"""
|
|
Get the hash of an open file-like object.
|
|
|
|
Parameters
|
|
------------
|
|
file_obj: file like object
|
|
hash_function: function to use to hash data
|
|
|
|
Returns
|
|
---------
|
|
hashed: str, hex version of result
|
|
"""
|
|
# before we read the file data save the current position
|
|
# in the file (which is probably 0)
|
|
file_position = file_obj.tell()
|
|
# create an instance of the hash object
|
|
hasher = hash_function()
|
|
# read all data from the file into the hasher
|
|
hasher.update(file_obj.read())
|
|
# get a hex version of the result
|
|
hashed = hasher.hexdigest()
|
|
# return the file object to its original position
|
|
file_obj.seek(file_position)
|
|
|
|
return hashed
|
|
|
|
|
|
def md5_object(obj):
|
|
"""
|
|
If an object is hashable, return the string of the MD5.
|
|
|
|
Parameters
|
|
------------
|
|
obj: object
|
|
|
|
Returns
|
|
----------
|
|
md5: str, MD5 hash
|
|
"""
|
|
hasher = hashlib.md5()
|
|
if isinstance(obj, basestring) and PY3:
|
|
# in python3 convert strings to bytes before hashing
|
|
hasher.update(obj.encode('utf-8'))
|
|
else:
|
|
hasher.update(obj)
|
|
|
|
md5 = hasher.hexdigest()
|
|
return md5
|
|
|
|
|
|
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.geos',
|
|
'shapely.speedups._speedups',
|
|
'parso.cache',
|
|
'parso.python.diff']
|
|
|
|
# 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
|
|
logger.name 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))
|
|
|
|
# allow a value to be repeated in a value format
|
|
repeats = value_format.count('{}')
|
|
|
|
if array.dtype.kind == 'i':
|
|
# 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 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).tostring())
|
|
if hasattr(packed, 'decode'):
|
|
packed = packed.decode('utf-8')
|
|
encoded['base64'] = packed
|
|
elif encoding == 'binary':
|
|
encoded['binary'] = array.tostring(order='C')
|
|
else:
|
|
raise ValueError('encoding {} is not available!'.format(encoding))
|
|
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.tostring
|
|
|
|
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:
|
|
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 = []
|
|
# we do the hasattr as None/NoneType can be in the list of bases
|
|
bases = [i for i in bases if hasattr(i, '__name__')]
|
|
return np.array(bases)
|
|
|
|
|
|
def type_named(obj, name):
|
|
"""
|
|
Similar to the type() builtin, but looks in class bases
|
|
for named instance.
|
|
|
|
Parameters
|
|
------------
|
|
obj: object to look for class of
|
|
name : str, name of class
|
|
|
|
Returns
|
|
----------
|
|
named 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):
|
|
"""
|
|
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 : trimesh.Trimesh
|
|
Concatenated mesh
|
|
"""
|
|
if b is None:
|
|
b = []
|
|
# stack meshes into flat list
|
|
meshes = np.append(a, b)
|
|
|
|
# extract the trimesh type to avoid a circular import
|
|
# and assert that both inputs are Trimesh objects
|
|
trimesh_type = type_named(meshes[0], 'Trimesh')
|
|
|
|
# append faces and vertices of meshes
|
|
vertices, faces = append_faces(
|
|
[m.vertices.copy() for m in meshes],
|
|
[m.faces.copy() for m in meshes])
|
|
|
|
# only save face normals if already calculated
|
|
face_normals = None
|
|
if all('face_normals' in m._cache for m in meshes):
|
|
face_normals = np.vstack([m.face_normals
|
|
for m in meshes])
|
|
|
|
try:
|
|
# concatenate visuals
|
|
visual = meshes[0].visual.concatenate(
|
|
[m.visual for m in meshes[1:]])
|
|
except BaseException:
|
|
log.warning('failed to combine visuals',
|
|
exc_info=True)
|
|
visual = None
|
|
# create the mesh object
|
|
mesh = trimesh_type(vertices=vertices,
|
|
faces=faces,
|
|
face_normals=face_normals,
|
|
visual=visual,
|
|
process=False)
|
|
|
|
return mesh
|
|
|
|
|
|
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
|
|
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])
|
|
visuals.append(mesh.visual.face_subset(index))
|
|
|
|
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:
|
|
if all(hasattr(i, 'concatenate') for i in visuals):
|
|
visuals = np.array(visuals)
|
|
visual = visuals[0].concatenate(visuals[1:])
|
|
else:
|
|
visual = None
|
|
vertices, faces = append_faces(vertices, faces)
|
|
appended = trimesh_type(
|
|
vertices=vertices,
|
|
faces=faces,
|
|
face_normals=np.vstack(normals),
|
|
visual=visual,
|
|
process=False)
|
|
return appended
|
|
|
|
# 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)]
|
|
result = np.array(result)
|
|
if only_watertight or repair:
|
|
# fill_holes will attempt a repair and returns the
|
|
# watertight status at the end of the repair attempt
|
|
watertight = np.array([i.fill_holes() and len(i.faces) >= 4
|
|
for i in result])
|
|
if only_watertight:
|
|
# remove unrepairable meshes
|
|
result = result[watertight]
|
|
|
|
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 : JSON-serializable blob
|
|
**kwargs :
|
|
Passed to json.dumps
|
|
|
|
Returns
|
|
--------------
|
|
dumped : str
|
|
JSON dump of obj
|
|
"""
|
|
class NumpyEncoder(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()
|
|
return json.JSONEncoder.default(self, obj)
|
|
# run the dumps using our encoder
|
|
dumped = json.dumps(obj, cls=NumpyEncoder, **kwargs)
|
|
return dumped
|
|
|
|
|
|
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 is_none(like):
|
|
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]
|
|
|
|
# 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
|
|
"""
|
|
# rtree is a soft dependency
|
|
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)
|
|
|
|
# some versions of rtree screw up indexes on stream loading
|
|
# do a test here so we know if we are free to use stream loading
|
|
# or if we have to do a loop to insert things which is 5x slower
|
|
rtree_test = rtree.index.Index(
|
|
[(1564, [0, 0, 0, 10, 10, 10], None)],
|
|
properties=rtree.index.Property(dimension=3))
|
|
rtree_stream_ok = next(rtree_test.intersection(
|
|
[1, 1, 1, 2, 2, 2])) == 1564
|
|
|
|
properties = rtree.index.Property(dimension=dimension)
|
|
if rtree_stream_ok:
|
|
# stream load was verified working on import above
|
|
tree = rtree.index.Index(zip(np.arange(len(bounds)),
|
|
bounds,
|
|
[None] * len(bounds)),
|
|
properties=properties)
|
|
else:
|
|
# in some rtree versions stream loading goofs the index
|
|
log.warning('rtree stream loading broken! Try upgrading rtree!')
|
|
tree = rtree.index.Index(properties=properties)
|
|
for i, b in enumerate(bounds):
|
|
tree.insert(i, b)
|
|
return tree
|
|
|
|
|
|
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 not PY3:
|
|
# in python 2 StringIO handles bytes and str
|
|
return StringIO(item)
|
|
if isinstance(item, str):
|
|
return StringIO(item)
|
|
elif isinstance(item, bytes):
|
|
return BytesIO(item)
|
|
raise ValueError('{} is not wrappable!'.format(type(item).__name__))
|
|
|
|
|
|
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.int).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 = np.round(values / (10**multiplier)).astype(np.int32)
|
|
|
|
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}
|
|
"""
|
|
|
|
def is_zip():
|
|
archive = zipfile.ZipFile(file_obj)
|
|
result = {name: wrap_as_stream(archive.read(name))
|
|
for name in archive.namelist()}
|
|
return result
|
|
|
|
def is_tar():
|
|
import tarfile
|
|
archive = tarfile.open(fileobj=file_obj, mode='r')
|
|
result = {name: archive.extractfile(name)
|
|
for name in archive.getnames()}
|
|
return result
|
|
|
|
file_type = str(file_type).lower()
|
|
if isinstance(file_obj, bytes):
|
|
file_obj = wrap_as_stream(file_obj)
|
|
|
|
if file_type[-3:] == 'zip':
|
|
return is_zip()
|
|
if 'tar' in file_type[-6:]:
|
|
return is_tar()
|
|
raise ValueError('Unsupported type passed!')
|
|
|
|
|
|
def compress(info):
|
|
"""
|
|
Compress data stored in a dict.
|
|
|
|
Parameters
|
|
-----------
|
|
info : dict
|
|
Data to compress in form:
|
|
{file name in archive: bytes or file-like object}
|
|
|
|
Returns
|
|
-----------
|
|
compressed : bytes
|
|
Compressed file data
|
|
"""
|
|
if PY3:
|
|
file_obj = BytesIO()
|
|
else:
|
|
file_obj = StringIO()
|
|
|
|
with zipfile.ZipFile(
|
|
file_obj,
|
|
mode='w',
|
|
compression=zipfile.ZIP_DEFLATED) 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=['tar.bz2', 'tar.gz']):
|
|
"""
|
|
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 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])
|
|
# looping through a list of lists is extremely slow
|
|
# combine all the sequences into a blob we can manipulate
|
|
blob = np.concatenate(strips)
|
|
|
|
# preallocate and slice the blob into rough triangles
|
|
tri = np.zeros((len(blob) - 2, 3), dtype=np.int)
|
|
for i in range(3):
|
|
tri[:len(blob) - 3, i] = blob[i:-3 + i]
|
|
# the last triangle is left off from the slicing, add it back
|
|
tri[-1] = blob[-3:]
|
|
|
|
# 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=np.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=np.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 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.
|
|
|
|
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 file_obj.mode
|
|
string_stuff = isinstance(stuff, basestring)
|
|
binary_stuff = isinstance(stuff, bytes)
|
|
|
|
if not PY3:
|
|
file_obj.write(stuff)
|
|
elif 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, increment=0):
|
|
"""
|
|
Generate a decent looking alphanumeric unique identifier.
|
|
First 16 bits are time-incrementing, followed by randomness.
|
|
|
|
This function is used as a nicer looking alternative to:
|
|
>>> uuid.uuid4().hex
|
|
|
|
Follows the advice in:
|
|
https://eager.io/blog/how-long-does-an-id-need-to-be/
|
|
|
|
Parameters
|
|
------------
|
|
length : int
|
|
Length of desired identifier
|
|
increment : int
|
|
Number to add to header uint16
|
|
useful if calling this function repeatedly
|
|
in a tight loop executing faster than time
|
|
can increment the header
|
|
|
|
Returns
|
|
------------
|
|
unique : str
|
|
Unique alphanumeric identifier
|
|
"""
|
|
# head the identifier with 16 bits of time information
|
|
# this provides locality and reduces collision chances
|
|
head = np.array((increment + now() * 10) % 2**16,
|
|
dtype=np.uint16).tostring()
|
|
# get a bunch of random bytes
|
|
random = np.random.random(int(np.ceil(length / 5))).tostring()
|
|
# encode the time header and random information as base64
|
|
# replace + and / with spaces
|
|
unique = base64.b64encode(head + random,
|
|
b' ').decode('utf-8')
|
|
# remove spaces and cut to length
|
|
unique = unique.replace(' ', '')[:length]
|
|
return unique
|
|
|
|
|
|
def generate_basis(z):
|
|
"""
|
|
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
|
|
|
|
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!')
|
|
|
|
# normalize vector in-place
|
|
z /= np.linalg.norm(z)
|
|
# 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 < 1e-12:
|
|
# 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):
|
|
"""
|
|
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
|
|
close = np.logical_and(diff > -atol, diff < atol)
|
|
|
|
return close
|
|
|
|
|
|
def allclose(a, b, atol):
|
|
"""
|
|
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 np.all(np.abs(a - b).max() < 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('key must be a string, got %s' % str(key))
|
|
if key in self:
|
|
raise KeyError('Cannot set new value to existing key %s' % 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)
|
|
|
|
|
|
class TemporaryDirectory(object):
|
|
"""
|
|
Same basic usage as tempfile.TemporaryDirectory
|
|
but functional in Python 2.7+.
|
|
|
|
Example
|
|
---------
|
|
```
|
|
with trimesh.util.TemporaryDirectory() as path:
|
|
writable = os.path.join(path, 'hi.txt')
|
|
```
|
|
"""
|
|
|
|
def __enter__(self):
|
|
self.path = tempfile.mkdtemp()
|
|
return self.path
|
|
|
|
def __exit__(self, *args, **kwargs):
|
|
shutil.rmtree(self.path)
|
|
|
|
|
|
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
|
|
detect = chardet.detect(text)
|
|
# warn on files that aren't UTF-8
|
|
log.warning(
|
|
'Data not {}! Trying {} (confidence {})'.format(
|
|
initial,
|
|
detect['encoding'],
|
|
detect['confidence']))
|
|
# try to decode again, unwrapped in try
|
|
text = text.decode(detect['encoding'])
|
|
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):
|
|
"""
|
|
Check if connected 2D points are counterclockwise.
|
|
|
|
Parameters
|
|
-----------
|
|
points : (n, 2) float
|
|
Connected points on a plane
|
|
|
|
Returns
|
|
----------
|
|
ccw : bool
|
|
True if points are counter-clockwise
|
|
"""
|
|
points = np.asanyarray(points, dtype=np.float64)
|
|
|
|
if (len(points.shape) != 2 or points.shape[1] != 2):
|
|
raise ValueError('CCW is only defined for 2D')
|
|
xd = np.diff(points[:, 0])
|
|
# sum along axis=1 with a dot product
|
|
yd = np.dot(np.column_stack((
|
|
points[:, 1],
|
|
points[:, 1])).reshape(-1)[1:-1].reshape((-1, 2)), [1, 1])
|
|
area = np.sum(xd * yd) * .5
|
|
ccw = area < 0
|
|
|
|
return ccw
|