Repository: mikedh/trimesh
Pull request #1040 (2020-10-23 16:58:16)

Release: 3MF Units, Penetration Depth, Bounds Speedup
- Move some unused calculations inside conditional of `bounds.oriented_bounds`
- Release and test #1039 adding "foot" units for 3MF files
- Release and test #1035 for the `python-fcl` interface.


Patch:
diff --git a/trimesh/bounds.py b/trimesh/bounds.py
index 47b76294..cc3d6418 100644
--- a/trimesh/bounds.py
+++ b/trimesh/bounds.py
@@ -105,7 +105,10 @@ def oriented_bounds_2D(points, qhull_options='QbB'):
     return transform, rectangle
 
 
-def oriented_bounds(obj, angle_digits=1, ordered=True, normal=None):
+def oriented_bounds(obj,
+                    angle_digits=1,
+                    ordered=True,
+                    normal=None):
     """
     Find the oriented bounding box for a Trimesh
 
@@ -159,25 +162,25 @@ def oriented_bounds(obj, angle_digits=1, ordered=True, normal=None):
         raise ValueError(
             'Oriented bounds must be passed a mesh or a set of points!')
 
-    # convert face normals to spherical coordinates on the upper hemisphere
-    # the vector_hemisphere call effectivly merges negative but otherwise
-    # identical vectors
-    spherical_coords = util.vector_to_spherical(
-        util.vector_hemisphere(hull_normals))
-    # the unique_rows call on merge angles gets unique spherical directions to check
-    # we get a substantial speedup in the transformation matrix creation
-    # inside the loop by converting to angles ahead of time
-    spherical_unique = grouping.unique_rows(spherical_coords,
-                                            digits=angle_digits)[0]
     min_volume = np.inf
     tic = util.now()
 
     # matrices which will rotate each hull normal to [0,0,1]
     if normal is None:
+        # convert face normals to spherical coordinates on the upper hemisphere
+        # the vector_hemisphere call effectivly merges negative but otherwise
+        # identical vectors
+        spherical_coords = util.vector_to_spherical(
+            util.vector_hemisphere(hull_normals))
+        # the unique_rows call on merge angles gets unique spherical directions to check
+        # we get a substantial speedup in the transformation matrix creation
+        # inside the loop by converting to angles ahead of time
+        spherical_unique = grouping.unique_rows(
+            spherical_coords, digits=angle_digits)[0]
         matrices = [np.linalg.inv(transformations.spherical_matrix(*s))
                     for s in spherical_coords[spherical_unique]]
     else:
-        # if explicit normal was passed use it
+        # if explicit normal was passed use it and skip the grouping
         matrices = [geometry.align_vectors(normal, [0, 0, 1])]
 
     for to_2D in matrices:
@@ -225,8 +228,7 @@ def oriented_bounds(obj, angle_digits=1, ordered=True, normal=None):
         min_extents = min_extents[order]
 
     log.debug('oriented_bounds checked %d vectors in %0.4fs',
-              len(spherical_unique),
-              util.now() - tic)
+              len(matrices), util.now() - tic)
 
     return to_origin, min_extents
 
diff --git a/trimesh/collision.py b/trimesh/collision.py
index 11a889a1..068cde33 100644
--- a/trimesh/collision.py
+++ b/trimesh/collision.py
@@ -31,6 +31,7 @@ class ContactData(object):
             names[1]: contact.b2
         }
         self._point = contact.pos
+        self._depth = contact.penetration_depth
 
     @property
     def point(self):
@@ -44,6 +45,18 @@ class ContactData(object):
         """
         return self._point
 
+    @property
+    def depth(self):
+        """
+        The penetration depth of the 3D point of intersection for this contact.
+
+        Returns
+        -------
+        depth : float
+          The penetration depth.
+        """
+        return self._depth
+
     def index(self, name):
         """
         Returns the index of the face in contact for the mesh with
diff --git a/trimesh/path/polygons.py b/trimesh/path/polygons.py
index d3ce384d..405e9014 100644
--- a/trimesh/path/polygons.py
+++ b/trimesh/path/polygons.py
@@ -84,14 +84,11 @@ def enclosure_tree(polygons):
     # a root or exterior curve has an even number of parents
     # wrap in dict call to avoid networkx view
     degree = dict(contains.in_degree())
-
     # convert keys and values to numpy arrays
     indexes = np.array(list(degree.keys()))
     degrees = np.array(list(degree.values()))
-
     # roots are curves with an even inward degree (parent count)
     roots = indexes[(degrees % 2) == 0]
-
     # if there are multiple nested polygons split the graph
     # so the contains logic returns the individual polygons
     if len(degrees) > 0 and degrees.max() > 1:
@@ -545,22 +542,29 @@ def sample(polygon, count, factor=1.5, max_iter=10):
     bounds = np.reshape(polygon.bounds, (2, 2))
     extents = bounds.ptp(axis=0)
 
-    hit = []
-    hit_count = 0
+    # how many points to check per loop iteration
     per_loop = int(count * factor)
 
+    # start with some rejection sampling
+    points = bounds[0] + extents * np.random.random((per_loop, 2))
+    # do the point in polygon test and append resulting hits
+    mask = vectorized.contains(polygon, *points.T)
+    hit = [points[mask]]
+    hit_count = len(hit[0])
+    # if our first non-looping check got enough samples exit
+    if hit_count >= count:
+        return hit[0][:count]
+
+    # if we have to do iterations loop here slowly
     for i in range(max_iter):
         # generate points inside polygons AABB
         points = np.random.random((per_loop, 2))
         points = (points * extents) + bounds[0]
-
         # do the point in polygon test and append resulting hits
         mask = vectorized.contains(polygon, *points.T)
         hit.append(points[mask])
-
         # keep track of how many points we've collected
         hit_count += len(hit[-1])
-
         # if we have enough points exit the loop
         if hit_count > count:
             break
diff --git a/trimesh/path/segments.py b/trimesh/path/segments.py
index 0fcef701..0e37163c 100644
--- a/trimesh/path/segments.py
+++ b/trimesh/path/segments.py
@@ -534,7 +534,6 @@ def to_svg(segments, digits=4, matrix=None, merge=True):
         raise ValueError('only for (n, 2, 2) segments!')
 
     # create the array to export
-
     # apply 2D transformation if passed
     if matrix is not None:
         segments = transformations.transform_points(
diff --git a/trimesh/primitives.py b/trimesh/primitives.py
index 0906fc12..be031af5 100644
--- a/trimesh/primitives.py
+++ b/trimesh/primitives.py
@@ -176,14 +176,15 @@ class _PrimitiveAttributes(object):
         return doc
 
     def __getattr__(self, key):
-        if '_' in key:
+        if key.startswith('_'):
             return super(_PrimitiveAttributes, self).__getattr__(key)
         elif key in self._defaults:
             return util.convert_like(self._data[key], self._defaults[key])
-        return super(_PrimitiveAttributes, self).__getattr__(key)
+        raise AttributeError(
+            "primitive object has no attribute '{}' ".format(key))
 
     def __setattr__(self, key, value):
-        if '_' in key:
+        if key.startswith('_'):
             return super(_PrimitiveAttributes, self).__setattr__(key, value)
         elif key in self._defaults:
             if self._mutable:
diff --git a/trimesh/resources/units_to_inches.json b/trimesh/resources/units_to_inches.json
index 606bf8dd..973ff1a4 100644
--- a/trimesh/resources/units_to_inches.json
+++ b/trimesh/resources/units_to_inches.json
@@ -3,6 +3,7 @@
   "mils": 0.001,
   "inches": 1.0,
   "feet": 12.0,
+  "foot": 12.0,
   "yards": 36.0,
   "miles": 63360,
   "angstroms": 3.937007874015748e-9,
diff --git a/trimesh/version.py b/trimesh/version.py
index 5a27afa2..1042d292 100644
--- a/trimesh/version.py
+++ b/trimesh/version.py
@@ -1,1 +1,1 @@
-__version__ = '3.8.11'
+__version__ = '3.8.12'


Test patch:
diff --git a/tests/test_3mf.py b/tests/test_3mf.py
index 1473fe47..1ea06ae1 100644
--- a/tests/test_3mf.py
+++ b/tests/test_3mf.py
@@ -22,6 +22,15 @@ class MFTest(g.unittest.TestCase):
         # should be 6 instances around the scene
         assert len(s.graph.nodes_geometry) == 1
 
+    def test_units(self):
+        # test our unit conversion function
+        converter = g.trimesh.units.unit_conversion
+        # these are the units listed in the 3MF spec as valid
+        units = ['micron', 'millimeter',
+                 'centimeter', 'inch', 'foot', 'meter']
+        # check conversion factor for all valid 3MF units
+        assert all(converter(u, 'inches') > 1e-12 for u in units)
+
 
 if __name__ == '__main__':
     g.trimesh.util.attach_to_log()
diff --git a/tests/test_collision.py b/tests/test_collision.py
index 0256ac01..14edb25e 100644
--- a/tests/test_collision.py
+++ b/tests/test_collision.py
@@ -37,6 +37,7 @@ class CollisionTest(g.unittest.TestCase):
 
         assert ret is True
         assert all(len(i.point) == 3 for i in data)
+        assert (int(i.depth) for i in data)
 
         if 'cube1' not in names:
             print('\n\n', m._objs.keys(), names)