Repository: mikedh/trimesh

Issue:
Scene.scale definition
Hello. Scene.scale claims to be the _mean_ of the bounding box edge lengths, but it is actually the _diameter_ of the bounding box.
Is the code wrong or the documentation wrong? 

https://github.com/mikedh/trimesh/blob/021af1fedb3c6cff0e42cba9eed140eea7f819e3/trimesh/scene/scene.py#L378C1-L391C48
```
    @caching.cache_decorator
    def scale(self) -> float:
        """
        The approximate scale of the mesh

        Returns
        -----------
        scale : float
          The mean of the bounding box edge lengths
        """
        extents = self.extents
        if extents is None:
            return 1.0
        return float((extents**2).sum() ** 0.5)
```
Problem when exporting binary ply
I am getting an error when importing a mesh with texture coordinates using the binary ply format. The error is `PLY is unexpected length!`.
See the example below to reproduce the issue. I am not taking care of a correct triangulation for this example for reproducibility, but the error also appears with "correct" meshes. The example works fine without the `face_attributes` and also fine for the `ascii`format.

```
import numpy as np
import trimesh

if __name__ == '__main__':
    n_verts = 10
    rng = np.random.RandomState(42)
    faces = rng.randint(0, n_verts, size=(2 * n_verts, 1))
    faces = np.mod(np.concatenate([faces + 0, faces + 1, faces + 2], axis=1), n_verts)
    vertices = rng.normal(size=(n_verts, 3))
    uv_coord = rng.uniform(0, 1, size=(faces.shape[0], 6))

    fa = {
        "texcoord": uv_coord.astype(np.float32)
    }
    for encoding in ["ascii", "binary"]:
        print(f"Checking {encoding}")
        try:
            file_name = f"/tmp/test_{encoding}.ply"
            mesh = trimesh.Trimesh(vertices,
                                   faces,
                                   process=False,
                                   face_attributes=fa)
            mesh.export(file_name, include_attributes=True, encoding=encoding)
            loaded_mesh = trimesh.load_mesh(file_name, process=False)
        except Exception as e:
            print(f"{encoding} failed with {e}")

```


Discussion:

Patch:
diff --git a/Dockerfile b/Dockerfile
index fe5705727..96479d6f9 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -63,7 +63,7 @@ COPY --chown=499 pyproject.toml .
 COPY --chown=499 ./.git ./.git/
 
 USER root
-RUN trimesh-setup --install=test,build,gltf_validator,llvmpipe,binvox
+RUN trimesh-setup --install=test,gltf_validator,llvmpipe,binvox
 USER user
 
 # install things like pytest
diff --git a/pyproject.toml b/pyproject.toml
index ec9f97aab..7d364f013 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,7 +5,7 @@ requires = ["setuptools >= 61.0", "wheel"]
 [project]
 name = "trimesh"
 requires-python = ">=3.7"
-version = "4.1.3"
+version = "4.1.4"
 authors = [{name = "Michael Dawson-Haggerty", email = "mikedh@kerfed.com"}]
 license = {file = "LICENSE.md"}
 description = "Import, export, process, analyze and view triangular meshes."
@@ -78,6 +78,7 @@ easy = [
     "embreex",
     "pillow",
     "vhacdx",
+    "xatlas",
 ]
 
 recommend = [
@@ -86,7 +87,6 @@ recommend = [
     "meshio",
     "pyglet<2",
     "psutil",
-    "xatlas",
     "scikit-image",
     "python-fcl",
     "manifold3d>=2.3.0"
diff --git a/trimesh/base.py b/trimesh/base.py
index 8c8b2d5a8..76909c4d1 100644
--- a/trimesh/base.py
+++ b/trimesh/base.py
@@ -565,24 +565,6 @@ def extents(self) -> Optional[NDArray[float64]]:
 
         return extents
 
-    @caching.cache_decorator
-    def scale(self) -> float:
-        """
-        A metric for the overall scale of the mesh, the length of the
-        diagonal of the axis aligned bounding box of the mesh.
-
-        Returns
-        ----------
-        scale : float
-          The length of the meshes AABB diagonal
-        """
-        # if mesh is empty just return no scale
-        if self.extents is None:
-            return 1.0
-        # make sure we are returning python floats
-        scale = float((self.extents**2).sum() ** 0.5)
-        return scale
-
     @caching.cache_decorator
     def centroid(self) -> NDArray[float64]:
         """
diff --git a/trimesh/boolean.py b/trimesh/boolean.py
index 5b68a31a6..8446342c4 100644
--- a/trimesh/boolean.py
+++ b/trimesh/boolean.py
@@ -67,6 +67,11 @@ def intersection(meshes, engine=None, **kwargs):
       Meshes to be processed
     engine : str
       Which backend to use, i.e. 'blender' or 'manifold'
+    solver_options: str
+      Fast has some limitations
+      Exact is slow but handles most of the cases
+    use_self: Bool
+      Self Intersection, Do self-union or self-intersection
 
     Returns
     ----------
@@ -100,7 +105,6 @@ def boolean_manifold(meshes, operation, debug=False, **kwargs):
         result_manifold = manifolds[0] - manifolds[1]
     elif operation == "union":
         result_manifold = manifolds[0]
-
         for manifold in manifolds[1:]:
             result_manifold = result_manifold + manifold
     elif operation == "intersection":
diff --git a/trimesh/caching.py b/trimesh/caching.py
index c05f6d871..6bc44065d 100644
--- a/trimesh/caching.py
+++ b/trimesh/caching.py
@@ -20,8 +20,10 @@
 ```
 """
 import os
+import sys
 import time
 from functools import wraps
+from hashlib import sha256 as _sha256
 
 import numpy as np
 
@@ -34,22 +36,17 @@
     from collections.abc import Mapping
 
 
-# sha256 is always available
-from hashlib import sha256 as _sha256
-
-
-def sha256(item):
+def sha256(item) -> int:
     return int(_sha256(item).hexdigest(), 16)
 
 
-try:
+if sys.version_info >= (3, 9):
     # blake2b is available on Python 3 and
     from hashlib import blake2b as _blake2b
 
     def hash_fallback(item):
-        return int(_blake2b(item).hexdigest(), 16)
-
-except BaseException:
+        return int(_blake2b(item, usedforsecurity=False).hexdigest(), 16)
+else:
     # fallback to sha256
     hash_fallback = sha256
 
diff --git a/trimesh/exchange/ply.py b/trimesh/exchange/ply.py
index 1fdb263c8..f494dc9a6 100644
--- a/trimesh/exchange/ply.py
+++ b/trimesh/exchange/ply.py
@@ -153,8 +153,8 @@ def _add_attributes_to_dtype(dtype, attributes):
             dtype.append((name, data.dtype))
         else:
             attribute_dtype = data.dtype if len(data.dtype) == 0 else data.dtype[0]
-            dtype.append((f"{name}_count", "u1"))
-            dtype.append((name, _numpy_type_to_ply_type(attribute_dtype), data.shape[1]))
+            dtype.append((f"{name}_count", "<u1"))
+            dtype.append((name, attribute_dtype, data.shape[1]))
     return dtype
 
 
diff --git a/trimesh/interfaces/blender.py b/trimesh/interfaces/blender.py
index 610a9be92..95b15caf8 100644
--- a/trimesh/interfaces/blender.py
+++ b/trimesh/interfaces/blender.py
@@ -32,9 +32,33 @@
 exists = _blender_executable is not None
 
 
-def boolean(meshes, operation="difference", debug=False):
+def boolean(
+    meshes,
+    operation: str = "difference",
+    use_exact: bool = False,
+    use_self: bool = False,
+    debug: bool = False,
+):
     """
     Run a boolean operation with multiple meshes using Blender.
+
+    Parameters
+    -----------
+    meshes
+      List of mesh objects to be operated on
+    operation
+      Type of boolean operation ("difference", "union", "intersect").
+    use_exact
+      Use the "exact" mode as opposed to the "fast" mode.
+    use_self
+      Whether to consider self-intersections.
+    debug
+      Provide additional output for troubleshooting.
+
+    Returns
+    ----------
+    result
+      The result of the boolean operation on the provided meshes.
     """
     if not exists:
         raise ValueError("No blender available!")
@@ -42,9 +66,15 @@ def boolean(meshes, operation="difference", debug=False):
     if operation == "INTERSECTION":
         operation = "INTERSECT"
 
+    if use_exact:
+        solver_options = "EXACT"
+    else:
+        solver_options = "FAST"
     # get the template from our resources folder
     template = resources.get("templates/blender_boolean.py.tmpl")
     script = template.replace("$OPERATION", operation)
+    script = script.replace("$SOLVER_OPTIONS", solver_options)
+    script = script.replace("$USE_SELF", f"{use_self}")
 
     with MeshScript(meshes=meshes, script=script, debug=debug) as blend:
         result = blend.run(_blender_executable + " --background --python $SCRIPT")
diff --git a/trimesh/parent.py b/trimesh/parent.py
index c9cbf54e9..77413ebcd 100644
--- a/trimesh/parent.py
+++ b/trimesh/parent.py
@@ -10,6 +10,7 @@
 
 from . import bounds, caching
 from . import transformations as tf
+from .caching import cache_decorator
 from .constants import tol
 from .util import ABC
 
@@ -152,6 +153,32 @@ def __radd__(self, other):
         # otherwise just use the regular add function
         return self.__add__(type(self)(other))
 
+    @cache_decorator
+    def scale(self) -> float:
+        """
+        A loosely specified "order of magnitude scale" for the
+        geometry which always returns a value and can be used
+        to make code more robust to large scaling differences.
+
+        It returns the diagonal of the axis aligned bounding box
+        or if anything is invalid or undefined, `1.0`.
+
+        Returns
+        ----------
+        scale : float
+          Approximate order of magnitude scale of the geometry.
+        """
+        # if geometry is empty return 1.0
+        if self.extents is None:
+            return 1.0
+
+        # get the length of the AABB diagonal
+        scale = float((self.extents**2).sum() ** 0.5)
+        if scale < tol.zero:
+            return 1.0
+
+        return scale
+
 
 class Geometry3D(Geometry):
 
diff --git a/trimesh/path/path.py b/trimesh/path/path.py
index 042acae9d..8c1f01ddf 100644
--- a/trimesh/path/path.py
+++ b/trimesh/path/path.py
@@ -267,24 +267,6 @@ def kdtree(self):
         kdtree = cKDTree(self.vertices.view(np.ndarray))
         return kdtree
 
-    @property
-    def scale(self) -> float:
-        """
-        What is a representitive number that reflects the magnitude
-        of the world holding the paths, for numerical comparisons.
-
-        Returns
-        ----------
-        scale : float
-          Approximate size of the world holding this path
-        """
-        # check to see if this path is empty first
-        if len(self.vertices) == 0:
-            return 1.0
-
-        # return the diagonal length of the AABB
-        return np.linalg.norm(self.vertices.ptp(axis=0))
-
     @caching.cache_decorator
     def length(self):
         """
diff --git a/trimesh/resources/templates/blender_boolean.py.tmpl b/trimesh/resources/templates/blender_boolean.py.tmpl
index f99e3dc1b..78096868e 100644
--- a/trimesh/resources/templates/blender_boolean.py.tmpl
+++ b/trimesh/resources/templates/blender_boolean.py.tmpl
@@ -56,6 +56,8 @@ if __name__ == '__main__':
         mod = mesh.modifiers.new('boolean', 'BOOLEAN')
         mod.object = other
         mod.operation = '$OPERATION'
+        mod.solver = '$SOLVER_OPTIONS'
+        mod.use_self = $USE_SELF
         # used mod.name instead of hard-coded "boolean"
         bpy.ops.object.modifier_apply(modifier=mod.name)
 
diff --git a/trimesh/visual/gloss.py b/trimesh/visual/gloss.py
index 21a2b0613..ed39efa9f 100644
--- a/trimesh/visual/gloss.py
+++ b/trimesh/visual/gloss.py
@@ -17,7 +17,7 @@ def specular_to_pbr(
     diffuseTexture=None,
     diffuseFactor=None,
     **kwargs,
-):
+) -> dict:
     """
     Convert the KHR_materials_pbrSpecularGlossiness to a
     metallicRoughness visual.
@@ -175,10 +175,14 @@ def get_specular_glossiness(
             specularGlossinessTexture = np.array(specularGlossinessTexture) / 255.0
             specularTexture, glossinessTexture = None, None
 
-            if (
-                len(specularGlossinessTexture.shape) == 2
-                or specularGlossinessTexture.shape[-1]
-            ) == 1:
+            if len(specularGlossinessTexture.shape) == 2:
+                # use the one channel as a multiplier for specular and glossiness
+                specularTexture = glossinessTexture = specularGlossinessTexture.reshape(
+                    specularGlossinessTexture.shape[0],
+                    specularGlossinessTexture.shape[1],
+                    1,
+                )
+            elif specularGlossinessTexture.shape[-1] == 1:
                 # use the one channel as a multiplier for specular and glossiness
                 specularTexture = glossinessTexture = specularGlossinessTexture[
                     ..., np.newaxis


Test patch:
diff --git a/tests/test_ply.py b/tests/test_ply.py
index 4747b9ff5..aa1a0559b 100644
--- a/tests/test_ply.py
+++ b/tests/test_ply.py
@@ -103,24 +103,30 @@ def test_face_attributes(self):
         # Test writing face attributes to a ply, by reading
         # them back and asserting the written attributes array matches
 
-        m = g.get_mesh("box.STL")
-        test_1d_attribute = g.np.copy(m.face_angles[:, 0])
-        test_nd_attribute = g.np.copy(m.face_angles)
-        m.face_attributes["test_1d_attribute"] = test_1d_attribute
-        m.face_attributes["test_nd_attribute"] = test_nd_attribute
-
-        export = m.export(file_type="ply")
-        reconstructed = g.roundtrip(export, file_type="ply")
-
-        face_attributes = reconstructed.metadata["_ply_raw"]["face"]["data"]
-        result_1d = face_attributes["test_1d_attribute"]
-        result_nd = face_attributes["test_nd_attribute"]["f1"]
-
-        g.np.testing.assert_almost_equal(result_1d, test_1d_attribute)
-        g.np.testing.assert_almost_equal(result_nd, test_nd_attribute)
-
-        no_attr = m.export(file_type="ply", include_attributes=False)
-        assert len(no_attr) < len(export)
+        for encoding in ["binary", "ascii"]:
+            for dt in [g.np.float32, g.np.float64]:
+                m = g.get_mesh("box.STL")
+                test_1d_attribute = g.np.copy(m.face_angles[:, 0])
+                test_nd_attribute = g.np.copy(m.face_angles)
+                m.face_attributes["test_1d_attribute"] = test_1d_attribute.astype(dt)
+                m.face_attributes["test_nd_attribute"] = test_nd_attribute.astype(dt)
+
+                export = m.export(file_type="ply", include_attributes=True, encoding=encoding)
+                reconstructed = g.roundtrip(export, file_type="ply", process=False)
+
+                face_attributes = reconstructed.metadata["_ply_raw"]["face"]["data"]
+                result_1d = face_attributes["test_1d_attribute"]
+                if encoding == "binary":
+                    # only binary format allows this
+                    result_nd = face_attributes["test_nd_attribute"]["f1"]
+                else:
+                    result_nd = face_attributes["test_nd_attribute"]
+
+                g.np.testing.assert_almost_equal(result_1d, test_1d_attribute)
+                g.np.testing.assert_almost_equal(result_nd, test_nd_attribute)
+
+                no_attr = m.export(file_type="ply", include_attributes=False)
+                assert len(no_attr) < len(export)
 
     def test_cases(self):
         a = g.get_mesh("featuretype.STL")