Repository: mikedh/trimesh
Pull request #2067 (2023-11-06 02:43:49)

Release: Manifold Booleans
- release #2018 which switches the default boolean engine to the python bindings for [manifold(https://github.com/elalish/manifold], which seems like a massive, massive improvement over previous options. OpenSCAD also switched to it as a boolean backend so the interfaces to OpenSCAD no longer makes sense IMO. 
  - in our simple-ish tests gets a 55x speedup over calling blender with subprocess 😎: `test_boolean.py:67    {'blender': 3.3091907501220703, 'manifold': 0.0628511905670166}`
- release #2064 
  - allow and test for unit strings to have a simple scale factor. I.e `0.01 * meters` is now valid. This is relatively strict (i.e `meters * 0.01` is not valid, `2meters/3` is not valid, etc) to avoid any stuffing of math other than a fully evaluated float into the units field. 




Patch:
diff --git a/docs/content/install.md b/docs/content/install.md
index 18c3a3cc..512cf49b 100644
--- a/docs/content/install.md
+++ b/docs/content/install.md
@@ -71,6 +71,7 @@ Trimesh has a lot of soft-required upstream packages, and we try to make sure th
 |`pytest-cov`| A plugin to calculate test coverage. | | `test`|
 |`pyinstrument`| A sampling based profiler for performance tweaking. | | `test`|
 |`vhacdx`| A binding for VHACD which provides convex decompositions | | `recommend`|
+|`manifold3d`| A binding for the Manifold mesh boolean engine | | `recommend`|
 
 ## Adding A Dependency
 
diff --git a/pyproject.toml b/pyproject.toml
index 600f1f97..1a0996b2 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.2"
+version = "4.0.3"
 authors = [{name = "Michael Dawson-Haggerty", email = "mikedh@kerfed.com"}]
 license = {file = "LICENSE.md"}
 description = "Import, export, process, analyze and view triangular meshes."
@@ -89,6 +89,7 @@ recommend = [
     "scikit-image",
     "python-fcl",
     "vhacdx",
+    "manifold3d"
 ]
 
 # this is the list of everything that is ever added anywhere
diff --git a/trimesh/boolean.py b/trimesh/boolean.py
index ba6cb291..9a69c7d4 100644
--- a/trimesh/boolean.py
+++ b/trimesh/boolean.py
@@ -2,8 +2,20 @@
 boolean.py
 -------------
 
-Do boolean operations on meshes using either Blender or OpenSCAD.
+Do boolean operations on meshes using either Blender or Manifold.
 """
+import warnings
+
+import numpy as np
+
+try:
+    from manifold3d import Manifold, Mesh
+except BaseException as E:
+    from .exceptions import ExceptionWrapper
+
+    Mesh = ExceptionWrapper(E)
+    Manifold = ExceptionWrapper(E)
+
 from . import interfaces
 
 
@@ -16,7 +28,7 @@ def difference(meshes, engine=None, **kwargs):
     meshes : list of trimesh.Trimesh
       Meshes to be processed
     engine : str
-      Which backend to use, i.e. 'blender' or 'scad'
+      Which backend to use, i.e. 'blender' or 'manifold'
 
     Returns
     ----------
@@ -35,7 +47,7 @@ def union(meshes, engine=None, **kwargs):
     meshes : list of trimesh.Trimesh
       Meshes to be processed
     engine : str
-      Which backend to use, i.e. 'blender' or 'scad'
+      Which backend to use, i.e. 'blender' or 'manifold'
 
     Returns
     ----------
@@ -54,7 +66,7 @@ def intersection(meshes, engine=None, **kwargs):
     meshes : list of trimesh.Trimesh
       Meshes to be processed
     engine : str
-      Which backend to use, i.e. 'blender' or 'scad'
+      Which backend to use, i.e. 'blender' or 'manifold'
 
     Returns
     ----------
@@ -65,35 +77,65 @@ def intersection(meshes, engine=None, **kwargs):
     return result
 
 
-def boolean_automatic(meshes, operation, **kwargs):
+def boolean_manifold(meshes, operation, debug=False, **kwargs):
     """
-    Automatically pick an engine for booleans based on availability.
-
-    Parameters
-    --------------
-    meshes : list of Trimesh
-      Meshes to be booleaned
-    operation : str
-      Type of boolean, i.e. 'union', 'intersection', 'difference'
-
-    Returns
-    ---------------
-    result : trimesh.Trimesh
-      Result of boolean operation
+    Run an operation on a set of meshes using the Manifold engine.
     """
-    if interfaces.blender.exists:
-        result = interfaces.blender.boolean(meshes, operation, **kwargs)
-    elif interfaces.scad.exists:
-        result = interfaces.scad.boolean(meshes, operation, **kwargs)
+    # Convert to manifold meshes
+    manifolds = [
+        Manifold.from_mesh(
+            Mesh(
+                vert_properties=np.asarray(mesh.vertices, dtype="float32"),
+                tri_verts=np.asarray(mesh.faces, dtype="int32"),
+            )
+        )
+        for mesh in meshes
+    ]
+
+    # Perform operations
+    if operation == "difference":
+        if len(meshes) != 2:
+            raise ValueError("Difference only defined over two meshes.")
+
+        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":
+        result_manifold = manifolds[0]
+
+        for manifold in manifolds[1:]:
+            result_manifold = result_manifold ^ manifold
     else:
-        raise ValueError("No backends available for boolean operations!")
-    return result
+        raise ValueError(f"Invalid boolean operation: '{operation}'")
+
+    # Convert back to trimesh meshes
+    from . import Trimesh
+
+    result_mesh = result_manifold.to_mesh()
+    out_mesh = Trimesh(vertices=result_mesh.vert_properties, faces=result_mesh.tri_verts)
+
+    return out_mesh
+
+
+def boolean_scad(*args, **kwargs):
+    warnings.warn(
+        "The OpenSCAD interface is deprecated, and Trimesh will instead"
+        " use Manifold ('manifold'), which should be equivalent. In future versions"
+        " of Trimesh, attempting to use engine 'scad' may raise an error.",
+        DeprecationWarning,
+        stacklevel=2,
+    )
+    return boolean_manifold(*args, **kwargs)
 
 
 # which backend boolean engines
 _engines = {
-    None: boolean_automatic,
-    "auto": boolean_automatic,
-    "scad": interfaces.scad.boolean,
+    None: boolean_manifold,
+    "auto": boolean_manifold,
+    "manifold": boolean_manifold,
+    "scad": boolean_scad,
     "blender": interfaces.blender.boolean,
 }
diff --git a/trimesh/exchange/dae.py b/trimesh/exchange/dae.py
index fda557f8..c4e38b3d 100644
--- a/trimesh/exchange/dae.py
+++ b/trimesh/exchange/dae.py
@@ -57,12 +57,19 @@ def load_collada(file_obj, resolver=None, ignore_broken=True, **kwargs):
         effect = m.effect
         material_map[m.id] = _parse_material(effect, resolver)
 
+    unit = c.assetInfo.unitmeter
+    if unit is None or np.isclose(unit, 1.0):
+        metadata = {"units": "meters"}
+    else:
+        metadata = {"units": f"{unit} * meters"}
+
     # name : kwargs
     meshes = {}
     # increments to enable `unique_name` to avoid n^2 behavior
     meshes_count = {}
     # list of dict
     graph = []
+
     for node in c.scene.nodes:
         _parse_node(
             node=node,
@@ -72,12 +79,10 @@ def load_collada(file_obj, resolver=None, ignore_broken=True, **kwargs):
             meshes_count=meshes_count,
             graph=graph,
             resolver=resolver,
+            metadata=metadata,
         )
 
-    # create kwargs for load_kwargs
-    result = {"class": "Scene", "graph": graph, "geometry": meshes}
-
-    return result
+    return {"class": "Scene", "graph": graph, "geometry": meshes}
 
 
 def export_collada(mesh, **kwargs):
@@ -164,7 +169,7 @@ def export_collada(mesh, **kwargs):
 
 
 def _parse_node(
-    node, parent_matrix, material_map, meshes, meshes_count, graph, resolver=None
+    node, parent_matrix, material_map, meshes, meshes_count, graph, resolver, metadata
 ):
     """
     Recursively parse COLLADA scene nodes.
@@ -233,6 +238,7 @@ def _parse_node(
                     "vertex_normals": normals,
                     "vertex_colors": colors,
                     "visual": vis,
+                    "metadata": metadata,
                 }
 
                 graph.append(
@@ -258,6 +264,7 @@ def _parse_node(
                     meshes_count=meshes_count,
                     graph=graph,
                     resolver=resolver,
+                    metadata=metadata,
                 )
 
     elif isinstance(node, collada.scene.CameraNode):
@@ -360,6 +367,8 @@ def _unparse_material(material):
     # TODO EXPORT TEXTURES
     if isinstance(material, visual.material.PBRMaterial):
         diffuse = material.baseColorFactor
+        if diffuse is None:
+            diffuse = np.array([255.0, 255.0, 255.0, 255.0])
         diffuse = diffuse / 255.0
         if diffuse is not None:
             diffuse = list(diffuse)
@@ -369,6 +378,8 @@ def _unparse_material(material):
             emission = [float(emission[0]), float(emission[1]), float(emission[2]), 1.0]
 
         shininess = material.roughnessFactor
+        if shininess is None:
+            shininess = 1.0
         if shininess is not None:
             shininess = 2.0 / shininess**2 - 2.0
 
diff --git a/trimesh/interfaces/__init__.py b/trimesh/interfaces/__init__.py
index 0878b76b..4f58ef4a 100644
--- a/trimesh/interfaces/__init__.py
+++ b/trimesh/interfaces/__init__.py
@@ -1,8 +1,4 @@
-# flake8: noqa
-
-from . import gmsh
-from . import scad
-from . import blender
+from . import blender, gmsh
 
 # add to __all__ as per pep8
-__all__ = ["scad", "blender"]
+__all__ = ["blender", "gmsh"]
diff --git a/trimesh/scene/scene.py b/trimesh/scene/scene.py
index 1fb80d99..f0260c85 100644
--- a/trimesh/scene/scene.py
+++ b/trimesh/scene/scene.py
@@ -955,7 +955,7 @@ class Scene(Geometry3D):
         return png
 
     @property
-    def units(self):
+    def units(self) -> str:
         """
         Get the units for every model in the scene, and
         raise a ValueError if there are mixed units.
@@ -975,7 +975,7 @@ class Scene(Geometry3D):
         return existing[0]
 
     @units.setter
-    def units(self, value):
+    def units(self, value: str):
         """
         Set the units for every model in the scene without
         converting any units just setting the tag.
@@ -988,7 +988,7 @@ class Scene(Geometry3D):
         for m in self.geometry.values():
             m.units = value
 
-    def convert_units(self, desired, guess=False):
+    def convert_units(self, desired: str, guess: bool = False) -> "Scene":
         """
         If geometry has units defined convert them to new units.
 
diff --git a/trimesh/units.py b/trimesh/units.py
index 0f893687..edafd206 100644
--- a/trimesh/units.py
+++ b/trimesh/units.py
@@ -8,9 +8,13 @@ sympy.physics.units or pint.
 """
 from . import resources
 from .constants import log
+from .parent import Geometry
 
+# scaling factors from various unit systems to inches
+_lookup = resources.get("units_to_inches.json", decode_json=True)
 
-def unit_conversion(current, desired):
+
+def unit_conversion(current: str, desired: str) -> float:
     """
     Calculate the conversion from one set of units to another.
 
@@ -26,16 +30,45 @@ def unit_conversion(current, desired):
     conversion : float
         Number to multiply by to put values into desired units
     """
-    # scaling factors from various unit systems to inches
-    to_inch = resources.get("units_to_inches.json", decode_json=True)
+    # convert to common system then return ratio between current and desired
+    return to_inch(current.strip().lower()) / to_inch(desired.strip().lower())
+
+
+def to_inch(unit: str) -> float:
+    """
+    Calculate the conversion to an arbitrary common unit.
+
+    Parameters
+    ------------
+    unit
+      Either a key in `units_to_inches.json` or in the simple
+      `{float} * {str}` form, i.e. "1.2 * meters". We don't
+      support arbitrary `eval` of any math string
+
+    Returns
+    ----------
+    conversion
+      Factor to multiply by to get to an `inch` system.
+    """
+    # see if the units are just in our lookup table
+    lookup = _lookup.get(unit.strip().lower(), None)
+    if lookup is not None:
+        return lookup
+
+    try:
+        # otherwise check to see if they are in the factor * unit form
+        value, key = unit.split("*")
+        return _lookup[key.strip()] * float(value)
+    except BaseException as E:
+        # add a helpful error message
+        message = (
+            f'arbitrary units must be in the form "1.21 * meters", not "{unit}" ({E})'
+        )
 
-    current = str(current).strip().lower()
-    desired = str(desired).strip().lower()
-    conversion = to_inch[current] / to_inch[desired]
-    return conversion
+    raise ValueError(message)
 
 
-def units_from_metadata(obj, guess=True):
+def units_from_metadata(obj: Geometry, guess: bool = True) -> str:
     """
     Try to extract hints from metadata and if that fails
     guess based on the object scale.
@@ -43,15 +76,15 @@ def units_from_metadata(obj, guess=True):
 
     Parameters
     ------------
-    obj: object
-        Has attributes 'metadata' (dict) and 'scale' (float)
-    guess : bool
-        If metadata doesn't indicate units, guess from scale
+    obj
+      A geometry object.
+    guess
+      If metadata doesn't have units make a "best guess"
 
     Returns
     ------------
-    units: str
-        A guess of what the units might be
+    units
+     A guess of what the units might be
     """
     to_inch = resources.get("units_to_inches.json", decode_json=True)
 
@@ -89,7 +122,7 @@ def units_from_metadata(obj, guess=True):
         return "inches"
 
 
-def _convert_units(obj, desired, guess=False):
+def _convert_units(obj: Geometry, desired: str, guess=False) -> None:
     """
     Given an object with scale and units try to scale
     to different units via the object's `apply_scale`.


Test patch:
diff --git a/tests/test_boolean.py b/tests/test_boolean.py
index ea77391c..45e0eda0 100644
--- a/tests/test_boolean.py
+++ b/tests/test_boolean.py
@@ -3,6 +3,16 @@ try:
 except BaseException:
     import generic as g
 
+try:
+    import manifold3d
+except BaseException:
+    manifold3d = None
+
+engines = [
+    ("blender", g.trimesh.interfaces.blender.exists),
+    ("manifold", manifold3d is not None),
+]
+
 
 class BooleanTest(g.unittest.TestCase):
     def setUp(self):
@@ -16,11 +26,7 @@ class BooleanTest(g.unittest.TestCase):
     def test_boolean(self):
         a, b = self.a, self.b
 
-        engines = [
-            ("blender", g.trimesh.interfaces.blender.exists),
-            ("scad", g.trimesh.interfaces.scad.exists),
-        ]
-
+        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
@@ -29,36 +35,41 @@ class BooleanTest(g.unittest.TestCase):
                 continue
 
             g.log.info("Testing boolean ops with engine %s", engine)
+
+            tic = g.time.time()
+
+            # do all booleans before checks so we can time the backends
             ab = a.difference(b, engine=engine)
+            ba = b.difference(a, engine=engine)
+            i = a.intersection(b, engine=engine)
+            u = a.union(b, engine=engine)
+
+            times[engine] = g.time.time() - tic
+
             assert ab.is_volume
             assert self.is_zero(ab.volume - self.truth["difference"])
 
             assert g.np.allclose(ab.bounds[0], a.bounds[0])
 
-            ba = b.difference(a, engine=engine)
             assert ba.is_volume
             assert self.is_zero(ba.volume - self.truth["difference"])
 
             assert g.np.allclose(ba.bounds[1], b.bounds[1])
 
-            i = a.intersection(b, engine=engine)
             assert i.is_volume
             assert self.is_zero(i.volume - self.truth["intersection"])
 
-            u = a.union(b, engine=engine)
             assert u.is_volume
             assert self.is_zero(u.volume - self.truth["union"])
 
             g.log.info("booleans succeeded with %s", engine)
 
+        g.log.info(times)
+
     def test_multiple(self):
         """
         Make sure boolean operations work on multiple meshes.
         """
-        engines = [
-            ("blender", g.trimesh.interfaces.blender.exists),
-            ("scad", g.trimesh.interfaces.scad.exists),
-        ]
         for _engine, exists in engines:
             if not exists:
                 continue
@@ -73,10 +84,6 @@ class BooleanTest(g.unittest.TestCase):
             assert g.np.isclose(r.volume, 8.617306056726884)
 
     def test_empty(self):
-        engines = [
-            ("blender", g.trimesh.interfaces.blender.exists),
-            ("scad", g.trimesh.interfaces.scad.exists),
-        ]
         for engine, exists in engines:
             if not exists:
                 continue
diff --git a/tests/test_dae.py b/tests/test_dae.py
index f6c17e35..0fb2638b 100644
--- a/tests/test_dae.py
+++ b/tests/test_dae.py
@@ -28,6 +28,9 @@ class DAETest(g.unittest.TestCase):
         assert len(scene.geometry) == 1
         assert len(scene.graph.nodes_geometry) == 1
 
+        conv = scene.convert_units("inch")
+        assert conv.units == 'inch'
+
     def test_shoulder(self):
         if collada is None:
             g.log.error("no pycollada to test!")
@@ -37,6 +40,10 @@ class DAETest(g.unittest.TestCase):
         assert len(scene.geometry) == 3
         assert len(scene.graph.nodes_geometry) == 3
 
+        assert scene.units != 'mm'
+        conv = scene.convert_units("mm")
+        assert conv.units == 'mm'
+
     def test_export(self):
         if collada is None:
             g.log.error("no pycollada to test!")
@@ -59,6 +66,10 @@ class DAETest(g.unittest.TestCase):
         assert rec.visual.uv.ptp(axis=0).ptp() > 1e-5
         assert s.visual.material.baseColorTexture.size == rec.visual.material.image.size
 
+        conv = s.convert_units("inch")
+        assert conv.units == 'inch'
+
+
     def test_material_round(self):
         """
         Test to make sure materials survive a roundtrip
diff --git a/tests/test_units.py b/tests/test_units.py
index 6878a641..bd734fe7 100644
--- a/tests/test_units.py
+++ b/tests/test_units.py
@@ -59,6 +59,24 @@ class UnitsTest(g.unittest.TestCase):
         # extents should scale exactly with unit conversion
         assert g.np.allclose(p.extents / extents_pre, 25.4, atol=0.01)
 
+    def test_arbitrary(self):
+        ac = g.np.allclose
+        to_inch = g.trimesh.units.to_inch
+
+        # check whitespace
+        assert ac(1.0, to_inch("in"))
+        assert ac(1.0, to_inch("1.00000* in"))
+        assert ac(1.0, to_inch("1.00    * in"))
+
+        # check centimeter conversion
+        assert ac(100, to_inch("m") / to_inch("0.01*m"))
+
+        # if we are currently in centimeters and want to go to meters
+        # it should be dividing it by 100
+        assert ac(
+            0.01, g.trimesh.units.unit_conversion(current="0.01*m", desired="meters")
+        )
+
 
 if __name__ == "__main__":
     g.trimesh.util.attach_to_log()