Repository: mikedh/trimesh
Pull request #2309 (2024-10-26 22:08:33)

Make boolean engine selection more flexible
Closes: #2306 

As announced, here's a patch that refactors the boolean engine autodetection.

I had to refactor a few things to make the two engines behave the same way and avoid circular imports.

`reduce_cascade` is now in `util.py`. I assumed that this is needed in other places, so I didn't move it into the new interfaces.manifold module.

`interfaces.blender` and `interfaces.manifold` are now dynamic imports. This avoids a circular import loop and allows autodetection at load time.


Patch:
diff --git a/trimesh/boolean.py b/trimesh/boolean.py
index 009d767d..82b54ffe 100644
--- a/trimesh/boolean.py
+++ b/trimesh/boolean.py
@@ -5,16 +5,9 @@ boolean.py
 Do boolean operations on meshes using either Blender or Manifold.
 """
 
-import numpy as np
-
-from . import exceptions, interfaces
-from .typed import Callable, NDArray, Optional, Sequence, Union
-
-try:
-    from manifold3d import Manifold, Mesh
-except BaseException as E:
-    Mesh = exceptions.ExceptionWrapper(E)
-    Manifold = exceptions.ExceptionWrapper(E)
+# for dynamic module imports
+from importlib import import_module
+from .typed import Optional, Sequence
 
 
 def difference(
@@ -106,138 +99,26 @@ def intersection(
     return _engines[engine](meshes, operation="intersection", **kwargs)
 
 
-def boolean_manifold(
-    meshes: Sequence,
-    operation: str,
-    check_volume: bool = True,
-    **kwargs,
-):
-    """
-    Run an operation on a set of meshes using the Manifold engine.
-
-    Parameters
-    ----------
-    meshes : list of trimesh.Trimesh
-      Meshes to be processed
-    operation
-      Which boolean operation to do.
-    check_volume
-      Raise an error if not all meshes are watertight
-      positive volumes. Advanced users may want to ignore
-      this check as it is expensive.
-    kwargs
-      Passed through to the `engine`.
-    """
-    if check_volume and not all(m.is_volume for m in meshes):
-        raise ValueError("Not all meshes are volumes!")
-
-    # Convert to manifold meshes
-    manifolds = [
-        Manifold(
-            mesh=Mesh(
-                vert_properties=np.array(mesh.vertices, dtype=np.float32),
-                tri_verts=np.array(mesh.faces, dtype=np.uint32),
-            )
-        )
-        for mesh in meshes
-    ]
-
-    # Perform operations
-    if operation == "difference":
-        if len(meshes) < 2:
-            raise ValueError("Difference only defined over two meshes.")
-        elif len(meshes) == 2:
-            # apply the single difference
-            result_manifold = manifolds[0] - manifolds[1]
-        elif len(meshes) > 2:
-            # union all the meshes to be subtracted from the final result
-            unioned = reduce_cascade(lambda a, b: a + b, manifolds[1:])
-            # apply the difference
-            result_manifold = manifolds[0] - unioned
-    elif operation == "union":
-        result_manifold = reduce_cascade(lambda a, b: a + b, manifolds)
-    elif operation == "intersection":
-        result_manifold = reduce_cascade(lambda a, b: a ^ b, manifolds)
-    else:
-        raise ValueError(f"Invalid boolean operation: '{operation}'")
-
-    # Convert back to trimesh meshes
-    from . import Trimesh
-
-    result_mesh = result_manifold.to_mesh()
-    return Trimesh(vertices=result_mesh.vert_properties, faces=result_mesh.tri_verts)
-
-
-def reduce_cascade(operation: Callable, items: Union[Sequence, NDArray]):
-    """
-    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 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
-        c d
-        e f
-        ab cd
-        ef g
-        abcd efg
-     -> abcdefg
-
-    Where `functools.reduce` would run and return:
-        a b
-        ab c
-        abc d
-        abcd e
-        abcde f
-        abcdef g
-     -> abcdefg
-
-    Parameters
-    ----------
-    operation
-      The function to call on pairs of items.
-    items
-      The flat list of items to apply operation against.
-    """
-    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:
-        # skip the loop overhead for a single pair
-        return operation(items[0], items[1])
-
-    for _ in range(int(1 + np.log2(len(items)))):
-        results = []
-        for i in np.arange(len(items) // 2) * 2:
-            results.append(operation(items[i], items[i + 1]))
-
-        if len(items) % 2:
-            results.append(items[-1])
-
-        items = results
-
-    # logic should have reduced to a single item
-    assert len(results) == 1
-
-    return results[0]
-
-
-# which backend boolean engines
-_engines = {
-    None: boolean_manifold,
-    "manifold": boolean_manifold,
-    "blender": interfaces.blender.boolean,
-}
+# supported backend boolean engines
+# ordered by preference
+all_engines = [
+    "manifold",
+    "blender",
+]
+
+# test which engines are actually usable
+_engines = {}
+available_engines = []
+for name in all_engines:
+    engine = import_module("trimesh.interfaces." + name)
+    if engine.exists:
+        _engines[name] = engine.boolean
+        available_engines.append(name)
+        if None not in _engines:
+            # pick the first available engine as the default
+            _engines[None] = engine.boolean
+
+def set_default_engine(engine):
+    if engine not in available_engines:
+        raise ValueError(f"Engine {engine} is not available on this system")
+    _engines[None] = _engines[engine]
diff --git a/trimesh/exchange/xaml.py b/trimesh/exchange/xaml.py
index a988a518..20a33926 100644
--- a/trimesh/exchange/xaml.py
+++ b/trimesh/exchange/xaml.py
@@ -136,7 +136,7 @@ def load_XAML(file_obj, *args, **kwargs):
             normals.append(c_normals)
 
     # compile the results into clean numpy arrays
-    result = {"units": "meters"}
+    result = {}
     result["vertices"], result["faces"] = util.append_faces(vertices, faces)
     result["face_colors"] = np.vstack(colors)
     result["vertex_normals"] = np.vstack(normals)
diff --git a/trimesh/interfaces/__init__.py b/trimesh/interfaces/__init__.py
index 4f58ef4a..bcdaab74 100644
--- a/trimesh/interfaces/__init__.py
+++ b/trimesh/interfaces/__init__.py
@@ -1,4 +1,4 @@
 from . import blender, gmsh
 
 # add to __all__ as per pep8
-__all__ = ["blender", "gmsh"]
+__all__ = ["gmsh"]
diff --git a/trimesh/interfaces/manifold.py b/trimesh/interfaces/manifold.py
new file mode 100644
index 00000000..dbc442cf
--- /dev/null
+++ b/trimesh/interfaces/manifold.py
@@ -0,0 +1,76 @@
+import numpy as np
+
+from .. import exceptions
+from ..typed import Sequence
+from ..util import reduce_cascade
+
+exists = False
+
+try:
+    from manifold3d import Manifold, Mesh
+    exists = True
+except BaseException as E:
+    Mesh = exceptions.ExceptionWrapper(E)
+    Manifold = exceptions.ExceptionWrapper(E)
+
+
+def boolean(
+    meshes: Sequence,
+    operation: str,
+    check_volume: bool = True,
+    **kwargs,
+):
+    """
+    Run an operation on a set of meshes using the Manifold engine.
+
+    Parameters
+    ----------
+    meshes : list of trimesh.Trimesh
+      Meshes to be processed
+    operation
+      Which boolean operation to do.
+    check_volume
+      Raise an error if not all meshes are watertight
+      positive volumes. Advanced users may want to ignore
+      this check as it is expensive.
+    kwargs
+      Passed through to the `engine`.
+    """
+    if check_volume and not all(m.is_volume for m in meshes):
+        raise ValueError("Not all meshes are volumes!")
+
+    # Convert to manifold meshes
+    manifolds = [
+        Manifold(
+            mesh=Mesh(
+                vert_properties=np.array(mesh.vertices, dtype=np.float32),
+                tri_verts=np.array(mesh.faces, dtype=np.uint32),
+            )
+        )
+        for mesh in meshes
+    ]
+
+    # Perform operations
+    if operation == "difference":
+        if len(meshes) < 2:
+            raise ValueError("Difference only defined over two meshes.")
+        elif len(meshes) == 2:
+            # apply the single difference
+            result_manifold = manifolds[0] - manifolds[1]
+        elif len(meshes) > 2:
+            # union all the meshes to be subtracted from the final result
+            unioned = reduce_cascade(lambda a, b: a + b, manifolds[1:])
+            # apply the difference
+            result_manifold = manifolds[0] - unioned
+    elif operation == "union":
+        result_manifold = reduce_cascade(lambda a, b: a + b, manifolds)
+    elif operation == "intersection":
+        result_manifold = reduce_cascade(lambda a, b: a ^ b, manifolds)
+    else:
+        raise ValueError(f"Invalid boolean operation: '{operation}'")
+
+    # Convert back to trimesh meshes
+    from .. import Trimesh
+
+    result_mesh = result_manifold.to_mesh()
+    return Trimesh(vertices=result_mesh.vert_properties, faces=result_mesh.tri_verts)
diff --git a/trimesh/path/polygons.py b/trimesh/path/polygons.py
index e481dd57..2636e35a 100644
--- a/trimesh/path/polygons.py
+++ b/trimesh/path/polygons.py
@@ -3,7 +3,7 @@ from shapely import ops
 from shapely.geometry import Polygon
 
 from .. import bounds, geometry, graph, grouping
-from ..boolean import reduce_cascade
+from ..util import reduce_cascade
 from ..constants import log
 from ..constants import tol_path as tol
 from ..transformations import transform_points
diff --git a/trimesh/util.py b/trimesh/util.py
index ab452e69..76ace265 100644
--- a/trimesh/util.py
+++ b/trimesh/util.py
@@ -23,7 +23,7 @@ from io import BytesIO, StringIO
 import numpy as np
 
 # use our wrapped types for wider version compatibility
-from .typed import Any, Iterable, List, Union
+from .typed import Any, Iterable, List, Union, Callable, NDArray, Optional, Sequence
 
 # create a default logger
 log = logging.getLogger("trimesh")
@@ -2473,3 +2473,70 @@ def unique_name(start, contains, counts=None):
     # this should really never happen since we looped
     # through the full length of contains
     raise ValueError("Unable to establish unique name!")
+
+
+def reduce_cascade(operation: Callable, items: Union[Sequence, NDArray]):
+    """
+    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 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
+        c d
+        e f
+        ab cd
+        ef g
+        abcd efg
+     -> abcdefg
+
+    Where `functools.reduce` would run and return:
+        a b
+        ab c
+        abc d
+        abcd e
+        abcde f
+        abcdef g
+     -> abcdefg
+
+    Parameters
+    ----------
+    operation
+      The function to call on pairs of items.
+    items
+      The flat list of items to apply operation against.
+    """
+    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:
+        # skip the loop overhead for a single pair
+        return operation(items[0], items[1])
+
+    for _ in range(int(1 + np.log2(len(items)))):
+        results = []
+        for i in np.arange(len(items) // 2) * 2:
+            results.append(operation(items[i], items[i + 1]))
+
+        if len(items) % 2:
+            results.append(items[-1])
+
+        items = results
+
+    # logic should have reduced to a single item
+    assert len(results) == 1
+
+    return results[0]
diff --git a/trimesh/viewer/widget.py b/trimesh/viewer/widget.py
index 3636a3da..a6e8c909 100644
--- a/trimesh/viewer/widget.py
+++ b/trimesh/viewer/widget.py
@@ -14,7 +14,7 @@ from pyglet import gl
 
 from .. import rendering
 from .trackball import Trackball
-from .windowed import SceneViewer, _geometry_hash
+from .windowed import SceneViewer, geometry_hash
 
 
 class SceneGroup(pyglet.graphics.Group):
@@ -249,7 +249,7 @@ class SceneWidget(glooey.Widget):
         self._draw()
 
     def _update_node(self, node_name, geometry_name, geometry, transform):
-        geometry_hash_new = _geometry_hash(geometry)
+        geometry_hash_new = geometry_hash(geometry)
         if self.vertex_list_hash.get(geometry_name) != geometry_hash_new:
             # if geometry has texture defined convert it to opengl form
             if hasattr(geometry, "visual") and hasattr(geometry.visual, "material"):


Test patch:
diff --git a/tests/test_boolean.py b/tests/test_boolean.py
index 9e842fa5..007790d9 100644
--- a/tests/test_boolean.py
+++ b/tests/test_boolean.py
@@ -5,16 +5,12 @@ except BaseException:
 
 import numpy as np
 
-try:
-    import manifold3d
-except BaseException:
-    manifold3d = None
-
 
-engines = [
-    ("blender", g.trimesh.interfaces.blender.exists),
-    ("manifold", manifold3d is not None),
-]
+# test only available engines by default
+engines = g.trimesh.boolean.available_engines
+# test all engines if all_dep is set
+if g.all_dependencies:
+    engines = g.trimesh.boolean.all_engines
 
 
 def test_boolean():
@@ -23,13 +19,7 @@ def test_boolean():
     truth = g.data["boolean"]
 
     times = {}
-    for engine, exists in engines:
-        # if we have all_dep set it means we should fail if
-        # engine is not installed so don't continue
-        if not exists:
-            g.log.warning("skipping boolean engine %s", engine)
-            continue
-
+    for engine in engines:
         g.log.info("Testing boolean ops with engine %s", engine)
 
         tic = g.time.time()
@@ -67,9 +57,9 @@ def test_multiple():
     """
     Make sure boolean operations work on multiple meshes.
     """
-    for engine, exists in engines:
-        if not exists:
-            continue
+    for engine in engines:
+        g.log.info("Testing multiple union with engine %s", engine)
+
         a = g.trimesh.primitives.Sphere(center=[0, 0, 0])
         b = g.trimesh.primitives.Sphere(center=[0, 0, 0.75])
         c = g.trimesh.primitives.Sphere(center=[0, 0, 1.5])
@@ -82,9 +72,8 @@ def test_multiple():
 
 
 def test_empty():
-    for engine, exists in engines:
-        if not exists:
-            continue
+    for engine in engines:
+        g.log.info("Testing empty intersection with engine %s", engine)
 
         a = g.trimesh.primitives.Sphere(center=[0, 0, 0])
         b = g.trimesh.primitives.Sphere(center=[5, 0, 0])
@@ -95,72 +84,72 @@ def test_empty():
 
 
 def test_boolean_manifold():
-    if manifold3d is None:
-        return
+    from trimesh.interfaces import manifold
 
-    times = {}
-    for operation in ["union", "intersection"]:
-        if operation == "union":
-            # chain of icospheres
-            meshes = [
-                g.trimesh.primitives.Sphere(center=[x / 2, 0, 0], subdivisions=0)
-                for x in range(100)
-            ]
-        else:
-            # closer icospheres for non-empty-intersection
-            meshes = [
-                g.trimesh.primitives.Sphere(center=[x, x, x], subdivisions=0)
-                for x in np.linspace(0, 0.5, 101)
-            ]
+    # run this test only when manifold3d is available when
+    # all_dep is enabled
+    if manifold.exists or g.all_dependencies:
 
-        # the old 'serial' manifold method
-        tic = g.time.time()
-        manifolds = [
-            manifold3d.Manifold(
-                mesh=manifold3d.Mesh(
-                    vert_properties=np.array(mesh.vertices, dtype=np.float32),
-                    tri_verts=np.array(mesh.faces, dtype=np.uint32),
+        times = {}
+        for operation in ["union", "intersection"]:
+            if operation == "union":
+                # chain of icospheres
+                meshes = [
+                    g.trimesh.primitives.Sphere(center=[x / 2, 0, 0], subdivisions=0)
+                    for x in range(100)
+                ]
+            else:
+                # closer icospheres for non-empty-intersection
+                meshes = [
+                    g.trimesh.primitives.Sphere(center=[x, x, x], subdivisions=0)
+                    for x in np.linspace(0, 0.5, 101)
+                ]
+
+            # the old 'serial' manifold method
+            tic = g.time.time()
+            manifolds = [
+                manifold.manifold3d.Manifold(
+                    mesh=manifold.manifold3d.Mesh(
+                        vert_properties=np.array(mesh.vertices, dtype=np.float32),
+                        tri_verts=np.array(mesh.faces, dtype=np.uint32),
+                    )
                 )
+                for mesh in meshes
+            ]
+            result_manifold = manifolds[0]
+            for manifold in manifolds[1:]:
+                if operation == "union":
+                    result_manifold = result_manifold + manifold
+                else:  # operation == "intersection":
+                    result_manifold = result_manifold ^ manifold
+            result_mesh = result_manifold.to_mesh()
+            old_mesh = g.trimesh.Trimesh(
+                vertices=result_mesh.vert_properties, faces=result_mesh.tri_verts
             )
-            for mesh in meshes
-        ]
-        result_manifold = manifolds[0]
-        for manifold in manifolds[1:]:
-            if operation == "union":
-                result_manifold = result_manifold + manifold
-            else:  # operation == "intersection":
-                result_manifold = result_manifold ^ manifold
-        result_mesh = result_manifold.to_mesh()
-        old_mesh = g.trimesh.Trimesh(
-            vertices=result_mesh.vert_properties, faces=result_mesh.tri_verts
-        )
-        times["serial " + operation] = g.time.time() - tic
+            times["serial " + operation] = g.time.time() - tic
 
-        # new 'binary' method
-        tic = g.time.time()
-        new_mesh = g.trimesh.boolean.boolean_manifold(meshes, operation)
-        times["binary " + operation] = g.time.time() - tic
+            # new 'binary' method
+            tic = g.time.time()
+            new_mesh = manifold.boolean(meshes, operation)
+            times["binary " + operation] = g.time.time() - tic
 
-        assert old_mesh.is_volume == new_mesh.is_volume
-        assert old_mesh.body_count == new_mesh.body_count
-        assert np.isclose(old_mesh.volume, new_mesh.volume)
+            assert old_mesh.is_volume == new_mesh.is_volume
+            assert old_mesh.body_count == new_mesh.body_count
+            assert np.isclose(old_mesh.volume, new_mesh.volume)
 
-    g.log.info(times)
+        g.log.info(times)
 
 
 def test_reduce_cascade():
     # the multiply will explode quickly past the integer maximum
-
     from functools import reduce
 
-    from trimesh.boolean import reduce_cascade
-
     def both(operation, items):
         """
         Run our cascaded reduce and regular reduce.
         """
 
-        b = reduce_cascade(operation, items)
+        b = g.trimesh.util.reduce_cascade(operation, items)
 
         if len(items) > 0:
             assert b == reduce(operation, items)
@@ -219,11 +208,7 @@ def test_multiple_difference():
     spheres = [g.trimesh.creation.icosphere()]
     spheres.extend(g.trimesh.creation.icosphere().apply_translation(c) for c in center)
 
-    for engine, exists in engines:
-        if not exists:
-            g.log.warning("skipping boolean engine %s", engine)
-            continue
-
+    for engine in engines:
         g.log.info("Testing multiple difference with engine %s", engine)
 
         # compute using meshes method