Repository: mikedh/trimesh
Pull request #2257 (2024-07-25 18:10:26)

Release: Fixes And Polygon Exit
- remove the `trimesh.triangles.crosses(... sum)` argument which was unused internally, unnecessary since you can just call `.sum()` on the result, and shadowed a built-in function.
- add an early exit for `enclosure_tree` for inputs with 0 or 1 value
- add another early exit for `trimesh.boolean.reduce_cascade` for inputs with a single value
- add a "manual" 2D cross product for `trimesh.triangles.cross`
- release and test #2231 
- release and test #2254 


Patch:
diff --git a/pyproject.toml b/pyproject.toml
index ff24cf28..ab3732e2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,7 +5,7 @@ requires = ["setuptools >= 61.0", "wheel"]
 [project]
 name = "trimesh"
 requires-python = ">=3.8"
-version = "4.4.3"
+version = "4.4.4"
 authors = [{name = "Michael Dawson-Haggerty", email = "mikedh@kerfed.com"}]
 license = {file = "LICENSE.md"}
 description = "Import, export, process, analyze and view triangular meshes."
@@ -77,8 +77,10 @@ easy = [
     "scipy",
     "embreex; platform_machine=='x86_64'",
     "pillow",
-    "vhacdx",
     "xatlas",
+    "vhacdx; python_version>='3.9'",
+    # old versions of mapbox_earcut produce incorrect values on numpy 2.0
+    "mapbox_earcut >= 1.0.2; python_version>='3.9'",
 ]
 
 recommend = [
@@ -91,7 +93,6 @@ recommend = [
     "python-fcl", # do collision checks
     "openctm", # load `CTM` compressed models
     "cascadio", # load `STEP` files
-    "mapbox-earcut", # BLOCKED FOR NUMPY2
     
 ]
 
@@ -103,7 +104,7 @@ test = [
     "pyright",
     "ezdxf",
     "pytest",
-    #     "pymeshlab; python_version<='3.11'",
+    "pymeshlab; python_version<='3.11'",
     "pyinstrument",
     "matplotlib",
     "ruff",
diff --git a/trimesh/base.py b/trimesh/base.py
index aafec90e..08200731 100644
--- a/trimesh/base.py
+++ b/trimesh/base.py
@@ -2667,7 +2667,7 @@ class Trimesh(Geometry3D):
         area_faces : (n, ) float
           Area of each face
         """
-        return triangles.area(crosses=self.triangles_cross, sum=False)
+        return triangles.area(crosses=self.triangles_cross)
 
     @caching.cache_decorator
     def mass_properties(self) -> MassProperties:
diff --git a/trimesh/boolean.py b/trimesh/boolean.py
index fc3d3b32..05896097 100644
--- a/trimesh/boolean.py
+++ b/trimesh/boolean.py
@@ -8,7 +8,7 @@ Do boolean operations on meshes using either Blender or Manifold.
 import numpy as np
 
 from . import exceptions, interfaces
-from .typed import Callable, Iterable, Optional
+from .typed import Callable, Optional, Sequence
 
 try:
     from manifold3d import Manifold, Mesh
@@ -18,7 +18,7 @@ except BaseException as E:
 
 
 def difference(
-    meshes: Iterable, engine: Optional[str] = None, check_volume: bool = True, **kwargs
+    meshes: Sequence, engine: Optional[str] = None, check_volume: bool = True, **kwargs
 ):
     """
     Compute the boolean difference between a mesh an n other meshes.
@@ -48,7 +48,7 @@ def difference(
 
 
 def union(
-    meshes: Iterable, engine: Optional[str] = None, check_volume: bool = True, **kwargs
+    meshes: Sequence, engine: Optional[str] = None, check_volume: bool = True, **kwargs
 ):
     """
     Compute the boolean union between a mesh an n other meshes.
@@ -79,7 +79,7 @@ def union(
 
 
 def intersection(
-    meshes: Iterable, engine: Optional[str] = None, check_volume: bool = True, **kwargs
+    meshes: Sequence, engine: Optional[str] = None, check_volume: bool = True, **kwargs
 ):
     """
     Compute the boolean intersection between a mesh and other meshes.
@@ -108,7 +108,7 @@ def intersection(
 
 
 def boolean_manifold(
-    meshes: Iterable,
+    meshes: Sequence,
     operation: str,
     check_volume: bool = True,
     **kwargs,
@@ -165,13 +165,21 @@ def boolean_manifold(
     return out_mesh
 
 
-def reduce_cascade(operation: Callable, items: Iterable):
+def reduce_cascade(operation: Callable, items: Sequence):
     """
-    Call a function in a cascaded pairwise way against a
-    flat sequence of items. This should produce the same
-    result as `functools.reduce` but may be faster for some
-    functions that for example perform only as fast as their
-    largest input.
+    Call an operation function in a cascaded pairwise way against a
+    flat list of items.
+
+    This should produce the same result as `functools.reduce`
+    if `operation` is commutable like addition or multiplication.
+    This will may be faster for an `operation` that runs
+    with a speed proportional to its largest input which mesh
+    booleans appear to. The union of a large number of small meshes
+    appears to be "much faster" using this method.
+
+    This only differs from `functools.reduce` for commutative `operation`
+    in that it returns `None` on empty inputs rather than `functools.reduce`
+    which raises a `TypeError`.
 
     For example on `a b c d e f g` this function would run and return:
         a b
@@ -200,8 +208,11 @@ def reduce_cascade(operation: Callable, items: Iterable):
     """
     if len(items) == 0:
         return None
+    elif len(items) == 1:
+        # skip the loop overhead for a single item
+        return items[0]
     elif len(items) == 2:
-        # might as well skip the loop overhead
+        # skip the loop overhead for a single pair
         return operation(items[0], items[1])
 
     for _ in range(int(1 + np.log2(len(items)))):
diff --git a/trimesh/convex.py b/trimesh/convex.py
index 396589e7..3e507b4c 100644
--- a/trimesh/convex.py
+++ b/trimesh/convex.py
@@ -91,7 +91,7 @@ def convex_hull(obj, qhull_options="QbB Pp Qt", repair=True):
     crosses = crosses[valid]
 
     # each triangle area and mean center
-    triangles_area = triangles.area(crosses=crosses, sum=False)
+    triangles_area = triangles.area(crosses=crosses)
     triangles_center = vertices[faces].mean(axis=1)
 
     # since the convex hull is (hopefully) convex, the vector from
diff --git a/trimesh/creation.py b/trimesh/creation.py
index 6f2deb10..5b0b4ac0 100644
--- a/trimesh/creation.py
+++ b/trimesh/creation.py
@@ -30,6 +30,7 @@ except BaseException as E:
 
 # get stored values for simple box and icosahedron primitives
 _data = get_json("creation.json")
+# check available triangulation engines without importing them
 _engines = [
     ("earcut", util.has_module("mapbox_earcut")),
     ("manifold", util.has_module("manifold3d")),
diff --git a/trimesh/parent.py b/trimesh/parent.py
index 45bb72b5..fa60b8d0 100644
--- a/trimesh/parent.py
+++ b/trimesh/parent.py
@@ -343,7 +343,7 @@ class Geometry3D(Geometry):
 
         if tol.strict:
             # obb transform should not have changed volume
-            if hasattr(self, "volume"):
+            if hasattr(self, "volume") and getattr(self, "is_watertight", False):
                 assert np.isclose(self.volume, volume)
             # overall extents should match what we expected
             assert np.allclose(self.extents, extents)
diff --git a/trimesh/path/exchange/misc.py b/trimesh/path/exchange/misc.py
index d035244d..de76fdcd 100644
--- a/trimesh/path/exchange/misc.py
+++ b/trimesh/path/exchange/misc.py
@@ -111,6 +111,7 @@ def polygon_to_path(polygon):
         "entities": entities,
         "vertices": np.vstack(vertices) if len(vertices) > 0 else vertices,
     }
+
     return kwargs
 
 
diff --git a/trimesh/path/polygons.py b/trimesh/path/polygons.py
index dda5af7a..2ff79f95 100644
--- a/trimesh/path/polygons.py
+++ b/trimesh/path/polygons.py
@@ -50,6 +50,16 @@ def enclosure_tree(polygons):
        contained by another polygon
     """
 
+    # nodes are indexes in polygons
+    contains = nx.DiGraph()
+
+    if len(polygons) == 0:
+        return np.array([], dtype=np.int64), contains
+    elif len(polygons) == 1:
+        # add an early exit for only a single polygon
+        contains.add_node(0)
+        return np.array([0], dtype=np.int64), contains
+
     # get the bounds for every valid polygon
     bounds = {
         k: v
@@ -59,8 +69,6 @@ def enclosure_tree(polygons):
         if len(v) == 4
     }
 
-    # nodes are indexes in polygons
-    contains = nx.DiGraph()
     # make sure we don't have orphaned polygon
     contains.add_nodes_from(bounds.keys())
 
@@ -551,13 +559,18 @@ def paths_to_polygons(paths, scale=None):
             # non-zero area
             continue
         try:
-            polygons[i] = repair_invalid(Polygon(path), scale)
+            polygon = Polygon(path)
+            if polygon.is_valid:
+                polygons[i] = polygon
+            else:
+                polygons[i] = repair_invalid(polygon, scale)
         except ValueError:
             # raised if a polygon is unrecoverable
             continue
         except BaseException:
             log.error("unrecoverable polygon", exc_info=True)
     polygons = np.array(polygons)
+
     return polygons
 
 
diff --git a/trimesh/transformations.py b/trimesh/transformations.py
index 6ef16374..6a3ad9da 100644
--- a/trimesh/transformations.py
+++ b/trimesh/transformations.py
@@ -323,7 +323,7 @@ def rotation_matrix(angle, direction, point=None):
     angle     : float, or sympy.Symbol
       Angle, in radians or symbolic angle
     direction : (3,) float
-      Unit vector along rotation axis
+      Any vector along rotation axis
     point     : (3, ) float, or None
       Origin point of rotation axis
 
diff --git a/trimesh/triangles.py b/trimesh/triangles.py
index 7af0513b..91128d10 100644
--- a/trimesh/triangles.py
+++ b/trimesh/triangles.py
@@ -16,7 +16,7 @@ from .typed import NDArray, Optional, float64
 from .util import diagonal_dot, unitize
 
 
-def cross(triangles):
+def cross(triangles: NDArray) -> NDArray:
     """
     Returns the cross product of two edges from input triangles
 
@@ -30,13 +30,19 @@ def cross(triangles):
     crosses : (n, 3) float
       Cross product of two edge vectors
     """
-    vectors = np.diff(triangles, axis=1)
-    crosses = np.cross(vectors[:, 0], vectors[:, 1])
+    vectors = triangles[:, 1:, :] - triangles[:, :2, :]
+    if triangles.shape[2] == 3:
+        return np.cross(vectors[:, 0], vectors[:, 1])
+    elif triangles.shape[2] == 2:
+        a = vectors[:, 0]
+        b = vectors[:, 1]
+        # numpy 2.0 deprecated 2D cross productes
+        return a[:, 0] * b[:, 1] - a[:, 1] * b[:, 0]
 
-    return crosses
+    raise ValueError(triangles.shape)
 
 
-def area(triangles=None, crosses=None, sum=False):
+def area(triangles=None, crosses=None):
     """
     Calculates the sum area of input triangles
 
@@ -55,11 +61,8 @@ def area(triangles=None, crosses=None, sum=False):
       Individual or summed area depending on `sum` argument
     """
     if crosses is None:
-        crosses = cross(triangles)
-    areas = np.sqrt((crosses**2).sum(axis=1)) / 2.0
-    if sum:
-        return areas.sum()
-    return areas
+        crosses = cross(np.asanyarray(triangles, dtype=np.float64))
+    return np.sqrt((crosses**2).sum(axis=1)) / 2.0
 
 
 def normals(triangles=None, crosses=None):
@@ -80,6 +83,8 @@ def normals(triangles=None, crosses=None):
     valid : (n,) bool
       Was the face nonzero area or not
     """
+    if triangles is not None and triangles.shape[-1] == 2:
+        return np.tile([0.0, 0.0, 1.0], (triangles.shape[0], 1))
     if crosses is None:
         crosses = cross(triangles)
     # unitize the cross product vectors
@@ -271,10 +276,9 @@ def mass_properties(
             + (triangles[:, 2, triangle_i] * g2[:, i])
         )
 
-    coefficients = 1.0 / np.array(
+    integrated = integral.sum(axis=1) / np.array(
         [6, 24, 24, 24, 60, 60, 60, 120, 120, 120], dtype=np.float64
     )
-    integrated = integral.sum(axis=1) * coefficients
 
     volume = integrated[0]
 
@@ -435,7 +439,7 @@ def extents(triangles, areas=None):
         raise ValueError("Triangles must be (n, 3, 3)!")
 
     if areas is None:
-        areas = area(triangles=triangles, sum=False)
+        areas = area(triangles=triangles)
 
     # the edge vectors which define the triangle
     a = triangles[:, 1] - triangles[:, 0]
diff --git a/trimesh/typed.py b/trimesh/typed.py
index ba4b8626..54cf65c8 100644
--- a/trimesh/typed.py
+++ b/trimesh/typed.py
@@ -5,8 +5,6 @@ from typing import (
     IO,
     Any,
     BinaryIO,
-    Callable,
-    Mapping,
     Optional,
     TextIO,
     Union,
@@ -22,9 +20,9 @@ if version_info >= (3, 9):
     List = list
     Tuple = tuple
     Dict = dict
-    from collections.abc import Iterable, Sequence
+    from collections.abc import Callable, Iterable, Mapping, Sequence
 else:
-    from typing import Dict, Iterable, List, Sequence, Tuple
+    from typing import Callable, Dict, Iterable, List, Mapping, Sequence, Tuple
 
 # most loader routes take `file_obj` which can either be
 # a file-like object or a file path, or sometimes a dict
diff --git a/trimesh/visual/gloss.py b/trimesh/visual/gloss.py
index 8c406568..ffad0e12 100644
--- a/trimesh/visual/gloss.py
+++ b/trimesh/visual/gloss.py
@@ -360,7 +360,7 @@ def specular_to_pbr(
         # we need to use RGB textures, because 2 channel textures can cause problems
         result["metallicRoughnessTexture"] = toPIL(
             np.concatenate(
-                [metallic, 1.0 - glossiness, np.zeros_like(metallic)], axis=-1
+                [np.zeros_like(metallic), 1.0 - glossiness, metallic], axis=-1
             ),
             mode="RGB",
         )


Test patch:
diff --git a/tests/test_creation.py b/tests/test_creation.py
index ac563859..ab328116 100644
--- a/tests/test_creation.py
+++ b/tests/test_creation.py
@@ -6,15 +6,7 @@ except BaseException:
 
 class CreationTest(g.unittest.TestCase):
     def setUp(self):
-        engines = []
-        if g.trimesh.util.has_module("triangle"):
-            engines.append("triangle")
-        if g.trimesh.util.has_module("mapbox_earcut"):
-            engines.append("earcut")
-        if g.trimesh.util.has_module("manifold3d"):
-            engines.append("manifold")
-
-        self.engines = engines
+        self.engines = [k for k, exists in g.trimesh.creation._engines if exists]
 
     def test_box(self):
         box = g.trimesh.creation.box
diff --git a/tests/test_gltf.py b/tests/test_gltf.py
index 8794c48b..10207f89 100644
--- a/tests/test_gltf.py
+++ b/tests/test_gltf.py
@@ -307,7 +307,8 @@ class GLTFTest(g.unittest.TestCase):
         )
         assert metallic_roughness.shape[0] == 84 and metallic_roughness.shape[1] == 71
 
-        metallic = metallic_roughness[:, :, 0]
+        # https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html#metallic-roughness-material
+        metallic = metallic_roughness[:, :, 2]
         roughness = metallic_roughness[:, :, 1]
 
         assert g.np.allclose(metallic[0, 0], 0.231, atol=0.004)