Repository: mikedh/trimesh

Issue:
Likely a bug in `trimesh.visual.gloss.specular_to_pbr`
According to the GLTF 2.0 specification, the metallicRoughnessTexture is stored as "green channel contains roughness values and its blue channel contains metalness values" 
![image](https://github.com/mikedh/trimesh/assets/35674081/54950e43-09c0-4ac5-ac76-7d34ce0bfe5c)
(https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html#metallic-roughness-material).

However, in the conversion of `specular_to_pbr` (shown as below), the current implementation puts the metallic value to the R channel, which seems to be a bug.
https://github.com/mikedh/trimesh/blob/563690aa2f36135e4442a3a8f3baf0f0eafc4fa9/trimesh/visual/gloss.py#L363

Discussion:
mikedh: Sorry for the delay on this! Looks good, I think the tests just need to be updated to match the change?

Patch:
diff --git a/trimesh/base.py b/trimesh/base.py
index 08200731..aafec90e 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)
+        return triangles.area(crosses=self.triangles_cross, sum=False)
 
     @caching.cache_decorator
     def mass_properties(self) -> MassProperties:
diff --git a/trimesh/convex.py b/trimesh/convex.py
index 3e507b4c..396589e7 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)
+    triangles_area = triangles.area(crosses=crosses, sum=False)
     triangles_center = vertices[faces].mean(axis=1)
 
     # since the convex hull is (hopefully) convex, the vector from
diff --git a/trimesh/path/exchange/misc.py b/trimesh/path/exchange/misc.py
index de76fdcd..d035244d 100644
--- a/trimesh/path/exchange/misc.py
+++ b/trimesh/path/exchange/misc.py
@@ -111,7 +111,6 @@ 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 c2d8edf4..dda5af7a 100644
--- a/trimesh/path/polygons.py
+++ b/trimesh/path/polygons.py
@@ -50,14 +50,6 @@ def enclosure_tree(polygons):
        contained by another polygon
     """
 
-    # nodes are indexes in polygons
-    contains = nx.DiGraph()
-
-    if 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
@@ -67,6 +59,8 @@ 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())
 
@@ -557,18 +551,13 @@ def paths_to_polygons(paths, scale=None):
             # non-zero area
             continue
         try:
-            polygon = Polygon(path)
-            if polygon.is_valid:
-                polygons[i] = polygon
-            else:
-                polygons[i] = repair_invalid(polygon, scale)
+            polygons[i] = repair_invalid(Polygon(path), 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/triangles.py b/trimesh/triangles.py
index b6efa328..7af0513b 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: NDArray) -> NDArray:
+def cross(triangles):
     """
     Returns the cross product of two edges from input triangles
 
@@ -30,19 +30,13 @@ def cross(triangles: NDArray) -> NDArray:
     crosses : (n, 3) float
       Cross product of two edge vectors
     """
-    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]
+    vectors = np.diff(triangles, axis=1)
+    crosses = np.cross(vectors[:, 0], vectors[:, 1])
 
-    raise ValueError(triangles.shape)
+    return crosses
 
 
-def area(triangles=None, crosses=None):
+def area(triangles=None, crosses=None, sum=False):
     """
     Calculates the sum area of input triangles
 
@@ -61,8 +55,11 @@ def area(triangles=None, crosses=None):
       Individual or summed area depending on `sum` argument
     """
     if crosses is None:
-        crosses = cross(np.asanyarray(triangles, dtype=np.float64))
-    return np.sqrt((crosses**2).sum(axis=1)) / 2.0
+        crosses = cross(triangles)
+    areas = np.sqrt((crosses**2).sum(axis=1)) / 2.0
+    if sum:
+        return areas.sum()
+    return areas
 
 
 def normals(triangles=None, crosses=None):
@@ -83,8 +80,6 @@ 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
@@ -440,7 +435,7 @@ def extents(triangles, areas=None):
         raise ValueError("Triangles must be (n, 3, 3)!")
 
     if areas is None:
-        areas = area(triangles=triangles)
+        areas = area(triangles=triangles, sum=False)
 
     # the edge vectors which define the triangle
     a = triangles[:, 1] - triangles[:, 0]
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_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)