Repository: mikedh/trimesh
Pull request #2094 (2023-12-14 02:38:50)

PLY and GLB parameters for skipping texture loading
Relates to #2093.

Since `obj` already implemented this via `skip_materials`, I implemented the same parameter for `glb` and similar parameter (`skip_texture`) for `ply`.


Patch:
diff --git a/pyproject.toml b/pyproject.toml
index c04cf0ee..47e1a8b6 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.0.6"
+version = "4.0.5"
 authors = [{name = "Michael Dawson-Haggerty", email = "mikedh@kerfed.com"}]
 license = {file = "LICENSE.md"}
 description = "Import, export, process, analyze and view triangular meshes."
diff --git a/trimesh/exchange/gltf.py b/trimesh/exchange/gltf.py
index 919a1db7..e6e5b12e 100644
--- a/trimesh/exchange/gltf.py
+++ b/trimesh/exchange/gltf.py
@@ -264,6 +264,7 @@ def load_gltf(
     resolver=None,
     ignore_broken=False,
     merge_primitives=False,
+    skip_materials=False,
     **mesh_kwargs,
 ):
     """
@@ -283,6 +284,8 @@ def load_gltf(
     merge_primitives : bool
       If True, each GLTF 'mesh' will correspond
       to a single Trimesh object
+    skip_materials : bool
+      If true, will not load materials (if present).
     **mesh_kwargs : dict
       Passed to mesh constructor
 
@@ -325,13 +328,19 @@ def load_gltf(
         ignore_broken=ignore_broken,
         merge_primitives=merge_primitives,
         mesh_kwargs=mesh_kwargs,
+        skip_materials=skip_materials,
         resolver=resolver,
     )
     return kwargs
 
 
 def load_glb(
-    file_obj, resolver=None, ignore_broken=False, merge_primitives=False, **mesh_kwargs
+    file_obj,
+    resolver=None,
+    ignore_broken=False,
+    merge_primitives=False,
+    skip_materials=False,
+    **mesh_kwargs
 ):
     """
     Load a GLTF file in the binary GLB format into a trimesh.Scene.
@@ -345,9 +354,15 @@ def load_glb(
       Containing GLB data
     resolver : trimesh.visual.Resolver
       Object which can be used to load other files by name
+    ignore_broken : bool
+      If there is a mesh we can't load and this
+      is True don't raise an exception but return
+      a partial result
     merge_primitives : bool
       If True, each GLTF 'mesh' will correspond to a
       single Trimesh object.
+    skip_materials : bool
+      If true, will not load materials (if present).
 
     Returns
     ------------
@@ -426,6 +441,7 @@ def load_glb(
         buffers=buffers,
         ignore_broken=ignore_broken,
         merge_primitives=merge_primitives,
+        skip_materials=skip_materials,
         mesh_kwargs=mesh_kwargs,
     )
 
@@ -1337,6 +1353,7 @@ def _read_buffers(
     mesh_kwargs,
     ignore_broken=False,
     merge_primitives=False,
+    skip_materials=False,
     resolver=None,
 ):
     """
@@ -1357,6 +1374,8 @@ def _read_buffers(
       a partial result
     merge_primitives : bool
       If true, combine primitives into a single mesh.
+    skip_materials : bool
+      If true, will not load materials (if present).
     resolver : trimesh.resolvers.Resolver
       Resolver to load referenced assets
 
@@ -1436,8 +1455,11 @@ def _read_buffers(
                 # a "sparse" accessor should be initialized as zeros
                 access[index] = np.zeros(count * per_count, dtype=dtype).reshape(shape)
 
-        # load images and textures into material objects
-        materials = _parse_materials(header, views=views, resolver=resolver)
+        # possibly load images and textures into material objects
+        if skip_materials:
+            materials = []
+        else:
+            materials = _parse_materials(header, views=views, resolver=resolver)
 
     mesh_prim = collections.defaultdict(list)
     # load data from accessors into Trimesh objects
@@ -1505,7 +1527,7 @@ def _read_buffers(
                         kwargs["vertex_normals"] = access[attr["NORMAL"]]
                         # do we have UV coordinates
                     visuals = None
-                    if "material" in p:
+                    if "material" in p and not skip_materials:
                         if materials is None:
                             log.debug("no materials! `pip install pillow`")
                         else:
@@ -1561,7 +1583,7 @@ def _read_buffers(
                 mesh_prim[index].append(name)
         except BaseException as E:
             if ignore_broken:
-                log.debug("failed to load mesh", exc_info=True)
+                log.debug("failed to load mesh", exc_info=True),
             else:
                 raise E
 
diff --git a/trimesh/exchange/load.py b/trimesh/exchange/load.py
index 6451a54c..8c9e2238 100644
--- a/trimesh/exchange/load.py
+++ b/trimesh/exchange/load.py
@@ -9,7 +9,6 @@ from ..exceptions import ExceptionWrapper
 from ..parent import Geometry
 from ..points import PointCloud
 from ..scene.scene import Scene, append_scenes
-from ..typed import Loadable, Optional
 from ..util import log, now
 from . import misc
 from .binvox import _binvox_loaders
@@ -33,11 +32,11 @@ except BaseException as E:
     load_path = ExceptionWrapper(E)
     # no path formats available
 
-    def path_formats() -> set:
+    def path_formats():
         return set()
 
 
-def mesh_formats() -> set:
+def mesh_formats():
     """
     Get a list of mesh formats available to load.
 
@@ -51,7 +50,7 @@ def mesh_formats() -> set:
     return {k for k, v in mesh_loaders.items() if not isinstance(v, ExceptionWrapper)}
 
 
-def available_formats() -> set:
+def available_formats():
     """
     Get a list of all available loaders
 
@@ -61,6 +60,7 @@ def available_formats() -> set:
         Extensions of available loaders
         i.e. 'stl', 'ply', 'dxf', etc.
     """
+    # set
     loaders = mesh_formats()
     loaders.update(path_formats())
     loaders.update(compressed_loaders.keys())
@@ -68,13 +68,7 @@ def available_formats() -> set:
     return loaders
 
 
-def load(
-    file_obj: Loadable,
-    file_type: Optional[str] = None,
-    resolver: Optional[resolvers.Resolver] = None,
-    force: Optional[str] = None,
-    **kwargs,
-):
+def load(file_obj, file_type=None, resolver=None, force=None, **kwargs):
     """
     Load a mesh or vectorized path into objects like
     Trimesh, Path2D, Path3D, Scene
@@ -161,12 +155,7 @@ def load(
     return loaded
 
 
-def load_mesh(
-    file_obj: Loadable,
-    file_type: Optional[str] = None,
-    resolver: Optional[resolvers.Resolver] = None,
-    **kwargs,
-):
+def load_mesh(file_obj, file_type=None, resolver=None, **kwargs):
     """
     Load a mesh file into a Trimesh object
 
@@ -502,12 +491,7 @@ def load_kwargs(*args, **kwargs):
     return handler()
 
 
-def parse_file_args(
-    file_obj: Loadable,
-    file_type: Optional[str],
-    resolver: Optional[resolvers.Resolver] = None,
-    **kwargs,
-):
+def parse_file_args(file_obj, file_type, resolver=None, **kwargs):
     """
     Given a file_obj and a file_type try to magically convert
     arguments to a file-like object and a lowercase string of
diff --git a/trimesh/exchange/ply.py b/trimesh/exchange/ply.py
index e4195881..2b09c878 100644
--- a/trimesh/exchange/ply.py
+++ b/trimesh/exchange/ply.py
@@ -64,7 +64,13 @@ def _numpy_type_to_ply_type(_numpy_type):
 
 
 def load_ply(
-    file_obj, resolver=None, fix_texture=True, prefer_color=None, *args, **kwargs
+    file_obj,
+    resolver=None,
+    fix_texture=True,
+    prefer_color=None,
+    skip_texture=False,
+    *args,
+    **kwargs
 ):
     """
     Load a PLY file from an open file object.
@@ -79,6 +85,8 @@ def load_ply(
       If True, will re- index vertices and faces
       so vertices with different UV coordinates
       are disconnected.
+    skip_texture : bool
+      If True, will not load texture (if present).
     prefer_color : None, 'vertex', or 'face'
       Which kind of color to prefer if both defined
 
@@ -100,18 +108,19 @@ def load_ply(
 
     # try to load the referenced image
     image = None
-    try:
-        # soft dependency
-        import PIL.Image
-
-        # if an image name is passed try to load it
-        if image_name is not None:
-            data = resolver.get(image_name)
-            image = PIL.Image.open(util.wrap_as_stream(data))
-    except ImportError:
-        log.debug("textures require `pip install pillow`")
-    except BaseException:
-        log.warning("unable to load image!", exc_info=True)
+    if not skip_texture:
+        try:
+            # soft dependency
+            import PIL.Image
+
+            # if an image name is passed try to load it
+            if image_name is not None:
+                data = resolver.get(image_name)
+                image = PIL.Image.open(util.wrap_as_stream(data))
+        except ImportError:
+            log.debug("textures require `pip install pillow`")
+        except BaseException:
+            log.warning("unable to load image!", exc_info=True)
 
     # translate loaded PLY elements to kwargs
     kwargs = _elements_to_kwargs(
diff --git a/trimesh/grouping.py b/trimesh/grouping.py
index 6897ac2c..83fc36fe 100644
--- a/trimesh/grouping.py
+++ b/trimesh/grouping.py
@@ -5,9 +5,9 @@ grouping.py
 Functions for grouping values and rows.
 """
 
+
 import numpy as np
 
-from .typed import ArrayLike, NDArray, Optional, Union
 from . import util
 from .constants import log, tol
 
@@ -157,9 +157,7 @@ def group(values, min_len=0, max_len=np.inf):
     return groups
 
 
-def hashable_rows(
-    data: ArrayLike, digits=None
-) -> Union[NDArray[np.uint64], NDArray[np.void]]:
+def hashable_rows(data, digits=None):
     """
     We turn our array into integers based on the precision
     given by digits and then put them in a hashable format.
@@ -174,8 +172,9 @@ def hashable_rows(
 
     Returns
     ---------
-    hashable : (n,)
-      May return as a `np.void` or a `np.uint64`
+    hashable : (n,) array
+      Custom data type which can be sorted
+      or used as hash keys
     """
     # if there is no data return immediately
     if len(data) == 0:
@@ -184,46 +183,35 @@ def hashable_rows(
     # get array as integer to precision we care about
     as_int = float_to_int(data, digits=digits)
 
-    # if it is flat integers already return
+    # if it is flat integers already, return
     if len(as_int.shape) == 1:
         return as_int
 
     # if array is 2D and smallish, we can try bitbanging
     # this is significantly faster than the custom dtype
     if len(as_int.shape) == 2 and as_int.shape[1] <= 4:
+        # time for some righteous bitbanging
         # can we pack the whole row into a single 64 bit integer
         precision = int(np.floor(64 / as_int.shape[1]))
-
-        # get the extreme values of the data set
-        d_min, d_max = as_int.min(), as_int.max()
-        # since we are quantizing the data down we need every value
-        # to fit in a partial integer so we have to check against extrema
-        threshold = 2 ** (precision - 1)
-
-        # if the data is within the range of our precision threshold
-        if d_max < threshold and d_min > -threshold:
+        # if the max value is less than precision we can do this
+        if np.abs(as_int).max() < 2 ** (precision - 1):
             # the resulting package
-            hashable = np.zeros(len(as_int), dtype=np.uint64)
-            # offset to the middle of the unsigned integer range
-            # this array should contain only positive values
-            bitbang = (as_int + threshold).astype(np.uint64).T
+            hashable = np.zeros(len(as_int), dtype=np.int64)
             # loop through each column and bitwise xor to combine
             # make sure as_int is int64 otherwise bit offset won't work
-            for offset, column in enumerate(bitbang):
+            for offset, column in enumerate(as_int.astype(np.int64).T):
                 # will modify hashable in place
                 np.bitwise_xor(hashable, column << (offset * precision), out=hashable)
             return hashable
 
-    # reshape array into magical data type that is weird but works with unique
+    # reshape array into magical data type that is weird but hashable
     dtype = np.dtype((np.void, as_int.dtype.itemsize * as_int.shape[1]))
     # make sure result is contiguous and flat
-    result = np.ascontiguousarray(as_int).view(dtype).reshape(-1)
-    result.flags["WRITEABLE"] = False
-
-    return result
+    hashable = np.ascontiguousarray(as_int).view(dtype).reshape(-1)
+    return hashable
 
 
-def float_to_int(data, digits: Optional[int] = None):
+def float_to_int(data, digits=None, dtype=np.int32):
     """
     Given a numpy array of float/bool/int, return as integers.
 
@@ -233,6 +221,8 @@ def float_to_int(data, digits: Optional[int] = None):
       Input data
     digits : float or int
       Precision for float conversion
+    dtype : numpy.dtype
+      What datatype should result be returned as
 
     Returns
     -------------
@@ -242,30 +232,32 @@ def float_to_int(data, digits: Optional[int] = None):
     # convert to any numpy array
     data = np.asanyarray(data)
 
-    # we can early-exit if we've been passed data that is already
-    # an integer, unsigned integer, boolean, or empty
-    if data.dtype.kind in "iub" or data.size == 0:
-        return data
+    # if data is already an integer or boolean we're done
+    # if the data is empty we are also done
+    if data.dtype.kind in "ib" or data.size == 0:
+        return data.astype(dtype)
     elif data.dtype.kind != "f":
-        # if it's not a floating point try to make it one
         data = data.astype(np.float64)
 
+    # populate digits from kwargs
     if digits is None:
-        # get digits from `tol.merge`
         digits = util.decimal_to_digits(tol.merge)
-    elif not isinstance(digits, (int, np.integer)):
-        raise TypeError("Digits must be `None` or `int`, not `{type(digits)}`")
-
-    # see if we can use a smaller integer
-    d_extrema = max(abs(data.min()), abs(data.max())) * 10**digits
-
-    # compare against `np.iinfo(np.int32).max`
-    dtype = [np.int32, np.int64][int(d_extrema > 2147483646)]
-
+    elif isinstance(digits, float) or isinstance(digits, np.float64):
+        digits = util.decimal_to_digits(digits)
+    elif not (isinstance(digits, int) or isinstance(digits, np.integer)):
+        log.warning("Digits were passed as %s!", digits.__class__.__name__)
+        raise ValueError("Digits must be None, int, or float!")
+
+    # data is float so convert to large integers
+    data_max = np.abs(data).max() * 10**digits
+    # ignore passed dtype if we have something large
+    dtype = [np.int32, np.int64][int(data_max > 2**31)]
     # multiply by requested power of ten
     # then subtract small epsilon to avoid "go either way" rounding
     # then do the rounding and convert to integer
-    return np.round((data * 10**digits) - 1e-6).astype(dtype)
+    as_int = np.round((data * 10**digits) - 1e-6).astype(dtype)
+
+    return as_int
 
 
 def unique_ordered(data, return_index=False, return_inverse=False):
diff --git a/trimesh/typed.py b/trimesh/typed.py
index 5527e304..b374f03f 100644
--- a/trimesh/typed.py
+++ b/trimesh/typed.py
@@ -1,4 +1,4 @@
-from typing import IO, Dict, List, Optional, Sequence, Tuple, Union
+from typing import Dict, List, Optional, Sequence, Tuple
 
 # our default integer and floating point types
 from numpy import float64, int64
@@ -9,16 +9,11 @@ except BaseException:
     NDArray = Sequence
     ArrayLike = Sequence
 
-# most loader routes take `file_obj` which can either be
-# a file-like object or a file path
-Loadable = Union[str, IO]
 
 __all__ = [
     "NDArray",
     "ArrayLike",
     "Optional",
-    "Loadable",
-    "IO",
     "List",
     "Dict",
     "Tuple",
diff --git a/trimesh/visual/gloss.py b/trimesh/visual/gloss.py
index 7f26442e..8756c7f3 100644
--- a/trimesh/visual/gloss.py
+++ b/trimesh/visual/gloss.py
@@ -180,9 +180,7 @@ def specular_to_pbr(
                 or specularGlossinessTexture.shape[-1]
             ) == 1:
                 # use the one channel as a multiplier for specular and glossiness
-                specularTexture = glossinessTexture = specularGlossinessTexture[
-                    ..., np.newaxis
-                ]
+                specularTexture = glossinessTexture = specularGlossinessTexture[..., np.newaxis]
             elif specularGlossinessTexture.shape[-1] == 3:
                 # all channels are specular, glossiness is only a factor
                 specularTexture = specularGlossinessTexture[..., :3]
diff --git a/trimesh/voxel/creation.py b/trimesh/voxel/creation.py
index c65b4235..2acc8bc8 100644
--- a/trimesh/voxel/creation.py
+++ b/trimesh/voxel/creation.py
@@ -8,7 +8,7 @@ from . import encoding as enc
 
 
 @log_time
-def voxelize_subdivide(mesh, pitch: float, max_iter: int = 10, edge_factor: float = 2.0):
+def voxelize_subdivide(mesh, pitch, max_iter=10, edge_factor=2.0):
     """
     Voxelize a surface by subdividing a mesh until every edge is
     shorter than: (pitch / edge_factor)
@@ -43,9 +43,11 @@ def voxelize_subdivide(mesh, pitch: float, max_iter: int = 10, edge_factor: floa
     )
 
     # convert the vertices to their voxel grid position
+    hit = v / pitch
+
     # Provided edge_factor > 1 and max_iter is large enough, this is
     # sufficient to preserve 6-connectivity at the level of voxels.
-    hit = np.round(v / pitch).astype(int)
+    hit = np.round(hit).astype(int)
 
     # remove duplicates
     unique, inverse = grouping.unique_rows(hit)
diff --git a/trimesh/voxel/ops.py b/trimesh/voxel/ops.py
index c96552bf..afdab885 100644
--- a/trimesh/voxel/ops.py
+++ b/trimesh/voxel/ops.py
@@ -135,9 +135,7 @@ def matrix_to_marching_cubes(matrix, pitch=1.0):
     if pitch.size == 1:
         pitch = (pitch,) * 3
     meshed = func(
-        volume=rev_matrix,
-        level=0.5,
-        spacing=pitch,  # it is a boolean voxel grid
+        volume=rev_matrix, level=0.5, spacing=pitch  # it is a boolean voxel grid
     )
 
     # allow results from either marching cubes function in skimage


Test patch:
diff --git a/tests/test_gltf.py b/tests/test_gltf.py
index 9168da58..a91b03d6 100644
--- a/tests/test_gltf.py
+++ b/tests/test_gltf.py
@@ -102,6 +102,21 @@ class GLTFTest(g.unittest.TestCase):
         a = g.json.loads(scene.export(file_type="gltf")["model.gltf"].decode("utf-8"))
         assert len(a["buffers"]) <= 3
 
+    def test_skip_materials(self):
+        # load textured PLY
+        mesh = g.get_mesh('fuze.ply')
+        g.check_fuze(mesh)
+
+        # load as GLB
+        export = mesh.export(file_type='glb', unitize_normals=True)
+        validate_glb(export)
+        mesh_glb = g.trimesh.load(
+            g.trimesh.util.wrap_as_stream(export),
+            file_type="glb", force="mesh", skip_materials=True)
+
+        # visuals should not be present
+        assert not mesh_glb.visual.defined
+
     def test_tex_export(self):
         # load textured PLY
         mesh = g.get_mesh("fuze.ply")
diff --git a/tests/test_graph.py b/tests/test_graph.py
index bc3f0c53..035e3f10 100644
--- a/tests/test_graph.py
+++ b/tests/test_graph.py
@@ -176,15 +176,9 @@ class GraphTest(g.unittest.TestCase):
                 )
 
                 # make a set from edges included in the traversal
-                inc_set = {
-                    i.tobytes()
-                    for i in g.trimesh.grouping.hashable_rows(g.np.sort(inc, axis=1))
-                }
+                inc_set = set(g.trimesh.grouping.hashable_rows(g.np.sort(inc, axis=1)))
                 # make a set of the source edges we were supposed to include
-                edge_set = {
-                    i.tobytes()
-                    for i in g.trimesh.grouping.hashable_rows(g.np.sort(edges, axis=1))
-                }
+                edge_set = set(g.trimesh.grouping.hashable_rows(g.np.sort(edges, axis=1)))
 
                 # we should have exactly the same edges
                 # after the filled traversal as we started with
diff --git a/tests/test_inertia.py b/tests/test_inertia.py
index 86797200..8b96b775 100644
--- a/tests/test_inertia.py
+++ b/tests/test_inertia.py
@@ -43,10 +43,7 @@ class InertiaTest(g.unittest.TestCase):
         assert g.np.abs(g.np.dot(t1, t1.T) - g.np.eye(4)).max() < 1e-10
 
         c = g.trimesh.primitives.Cylinder(
-            height=10,
-            radius=1,
-            sections=720,
-            transform=t0,  # number of slices
+            height=10, radius=1, sections=720, transform=t0  # number of slices
         )
         c0m = c.moment_inertia.copy()
         c0 = g.trimesh.inertia.cylinder_inertia(
@@ -346,7 +343,9 @@ class InertiaTest(g.unittest.TestCase):
         # CHECK FRAME 0
         # analytical calculations of inertia tensor by hand
         inertia0 = (
-            0.083333333333 * mass * g.np.diag([h**2 + b**2, h**2 + d**2, b**2 + d**2])
+            0.083333333333
+            * mass
+            * g.np.diag([h**2 + b**2, h**2 + d**2, b**2 + d**2])
         )
         a1 = -0.5 * d
         a2 = -0.5 * b
@@ -359,7 +358,9 @@ class InertiaTest(g.unittest.TestCase):
         # CHECK FRAME 1
         # analytical calculations of inertia tensor by hand
         inertia1 = (
-            0.083333333333 * mass * g.np.diag([h**2 + d**2, h**2 + b**2, b**2 + d**2])
+            0.083333333333
+            * mass
+            * g.np.diag([h**2 + d**2, h**2 + b**2, b**2 + d**2])
         )
         a1 = -0.5 * b
         a2 = 0.5 * d
@@ -375,7 +376,9 @@ class InertiaTest(g.unittest.TestCase):
         # CHECK FRAME 2
         # analytical calculations of inertia tensor by hand
         inertia2 = (
-            0.083333333333 * mass * g.np.diag([h**2 + b**2, b**2 + d**2, h**2 + d**2])
+            0.083333333333
+            * mass
+            * g.np.diag([h**2 + b**2, b**2 + d**2, h**2 + d**2])
         )
         a1 = -0.5 * d
         a2 = 0.5 * h
diff --git a/tests/test_obj.py b/tests/test_obj.py
index 2a0037ea..2e8e975f 100644
--- a/tests/test_obj.py
+++ b/tests/test_obj.py
@@ -175,6 +175,16 @@ class OBJTest(g.unittest.TestCase):
             r = g.trimesh.load(file_path)
             g.check_fuze(r)
 
+    def test_skip_mtl(self):
+        # not loading materials should produce a trivial texture
+        m_tex = g.get_mesh("fuze.obj")
+        m_tex_size = m_tex.visual.material.image.size
+
+        m_notex = g.get_mesh('fuze.obj', skip_materials=True)
+        m_notex_size = m_notex.visual.material.image.size
+
+        assert m_tex_size != m_notex_size
+
     def test_mtl(self):
         # get a mesh with texture
         m = g.get_mesh("fuze.obj")
diff --git a/tests/test_ply.py b/tests/test_ply.py
index 527d70c1..accdf3e1 100644
--- a/tests/test_ply.py
+++ b/tests/test_ply.py
@@ -232,6 +232,16 @@ class PlyTest(g.unittest.TestCase):
         # correct number of vertices and has texture loaded
         g.check_fuze(m)
 
+    def test_skip_texturefile(self):
+        # not loading the texture should produce a trivial texture
+        m_tex = g.get_mesh("fuze.ply")
+        m_tex_size = m_tex.visual.material.image.size
+
+        m_notex = g.get_mesh('fuze.ply', skip_texture=True)
+        m_notex_size = m_notex.visual.material.image.size
+
+        assert m_tex_size != m_notex_size
+
     def test_metadata(self):
         mesh = g.get_mesh("metadata.ply")