From 9799ad6ea8389144957c2bcddfa867fe9d1011a5 Mon Sep 17 00:00:00 2001 From: Gregor Riepl Date: Sat, 26 Oct 2024 14:53:05 +0200 Subject: [PATCH 01/14] Factor out boolean engine and improve selection/testing --- tests/test_boolean.py | 53 ++++------ trimesh/boolean.py | 172 +++++---------------------------- trimesh/interfaces/__init__.py | 2 +- trimesh/interfaces/blender.py | 2 + trimesh/interfaces/manifold.py | 143 +++++++++++++++++++++++++++ 5 files changed, 190 insertions(+), 182 deletions(-) create mode 100644 trimesh/interfaces/manifold.py diff --git a/tests/test_boolean.py b/tests/test_boolean.py index 9e842fa54..173933d89 100644 --- a/tests/test_boolean.py +++ b/tests/test_boolean.py @@ -5,16 +5,12 @@ 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 +# if we have all_dep set it means we should test all engines +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,8 +84,7 @@ def test_empty(): def test_boolean_manifold(): - if manifold3d is None: - return + from trimesh.interfaces import manifold times = {} for operation in ["union", "intersection"]: @@ -116,8 +104,8 @@ def test_boolean_manifold(): # the old 'serial' manifold method tic = g.time.time() manifolds = [ - manifold3d.Manifold( - mesh=manifold3d.Mesh( + 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), ) @@ -138,7 +126,7 @@ def test_boolean_manifold(): # new 'binary' method tic = g.time.time() - new_mesh = g.trimesh.boolean.boolean_manifold(meshes, operation) + new_mesh = manifold.boolean(meshes, operation) times["binary " + operation] = g.time.time() - tic assert old_mesh.is_volume == new_mesh.is_volume @@ -150,17 +138,16 @@ def test_boolean_manifold(): def test_reduce_cascade(): # the multiply will explode quickly past the integer maximum - from functools import reduce - from trimesh.boolean import reduce_cascade + from trimesh.interfaces import manifold def both(operation, items): """ Run our cascaded reduce and regular reduce. """ - b = reduce_cascade(operation, items) + b = manifold.reduce_cascade(operation, items) if len(items) > 0: assert b == reduce(operation, items) @@ -219,11 +206,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 diff --git a/trimesh/boolean.py b/trimesh/boolean.py index 009d767d3..f6b61f616 100644 --- a/trimesh/boolean.py +++ b/trimesh/boolean.py @@ -4,17 +4,8 @@ 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) +from . import interfaces +from .typed import Optional, Sequence def difference( @@ -106,138 +97,27 @@ 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 +# preferred engines are at the top +_backends = [ + interfaces.manifold, + interfaces.blender, +] + +# test which engines are actually usable +_engines = {} +available_engines = () +all_engines = () +for e in _backends: + all_engines = all_engines + e.name + if e.exists: + _engines[e.name] = e.boolean + available_engines = available_engines + e.name + if None not in _engines: + # pick the first available engine as the default + _engines[None] = e.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] = available_engines[engine].boolean diff --git a/trimesh/interfaces/__init__.py b/trimesh/interfaces/__init__.py index 4f58ef4af..7e8848afd 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__ = ["blender", "gmsh", "manifold"] diff --git a/trimesh/interfaces/blender.py b/trimesh/interfaces/blender.py index 169686ab6..495dde340 100644 --- a/trimesh/interfaces/blender.py +++ b/trimesh/interfaces/blender.py @@ -6,6 +6,8 @@ from ..typed import Iterable from .generic import MeshScript +name = "blender" + if platform.system() == "Windows": # try to find Blender install on Windows # split existing path by delimiter diff --git a/trimesh/interfaces/manifold.py b/trimesh/interfaces/manifold.py new file mode 100644 index 000000000..10b48f2e9 --- /dev/null +++ b/trimesh/interfaces/manifold.py @@ -0,0 +1,143 @@ +import numpy as np + +from .. import exceptions +from ..typed import Callable, NDArray, Sequence, Union + +name = "manifold" +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) + + +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] From f5fd2d82be69fbfa1d4a398ca9b0612389c73681 Mon Sep 17 00:00:00 2001 From: Gregor Riepl Date: Sat, 26 Oct 2024 22:51:00 +0200 Subject: [PATCH 02/14] Move reduce_cascade back into boolean module --- tests/test_boolean.py | 4 +- trimesh/boolean.py | 71 +++++++++++++++++++++++++++++++++- trimesh/interfaces/manifold.py | 70 +-------------------------------- 3 files changed, 73 insertions(+), 72 deletions(-) diff --git a/tests/test_boolean.py b/tests/test_boolean.py index 173933d89..8bf270d13 100644 --- a/tests/test_boolean.py +++ b/tests/test_boolean.py @@ -140,14 +140,12 @@ def test_reduce_cascade(): # the multiply will explode quickly past the integer maximum from functools import reduce - from trimesh.interfaces import manifold - def both(operation, items): """ Run our cascaded reduce and regular reduce. """ - b = manifold.reduce_cascade(operation, items) + b = g.trimesh.boolean.reduce_cascade(operation, items) if len(items) > 0: assert b == reduce(operation, items) diff --git a/trimesh/boolean.py b/trimesh/boolean.py index f6b61f616..bdc60e5f6 100644 --- a/trimesh/boolean.py +++ b/trimesh/boolean.py @@ -4,8 +4,10 @@ Do boolean operations on meshes using either Blender or Manifold. """ +import numpy as np + from . import interfaces -from .typed import Optional, Sequence +from .typed import Callable, NDArray, Optional, Sequence, Union def difference( @@ -97,6 +99,73 @@ def intersection( return _engines[engine](meshes, operation="intersection", **kwargs) +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] + + # supported backend boolean engines # preferred engines are at the top _backends = [ diff --git a/trimesh/interfaces/manifold.py b/trimesh/interfaces/manifold.py index 10b48f2e9..f52770297 100644 --- a/trimesh/interfaces/manifold.py +++ b/trimesh/interfaces/manifold.py @@ -1,7 +1,8 @@ import numpy as np from .. import exceptions -from ..typed import Callable, NDArray, Sequence, Union +from ..boolean import reduce_cascade +from ..typed import Sequence name = "manifold" exists = False @@ -74,70 +75,3 @@ def boolean( 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] From 3d7550ef6e078d7e2acd138f94e96fbc9a1e97eb Mon Sep 17 00:00:00 2001 From: Gregor Riepl Date: Sun, 27 Oct 2024 00:04:01 +0200 Subject: [PATCH 03/14] Break circular imports by making the boolean engines dynamic --- tests/test_boolean.py | 92 ++++++++++++++++---------------- trimesh/boolean.py | 96 +++++----------------------------- trimesh/interfaces/__init__.py | 2 +- trimesh/interfaces/blender.py | 2 - trimesh/interfaces/manifold.py | 3 +- trimesh/path/polygons.py | 2 +- trimesh/util.py | 69 +++++++++++++++++++++++- 7 files changed, 133 insertions(+), 133 deletions(-) diff --git a/tests/test_boolean.py b/tests/test_boolean.py index 8bf270d13..007790d95 100644 --- a/tests/test_boolean.py +++ b/tests/test_boolean.py @@ -8,7 +8,7 @@ # test only available engines by default engines = g.trimesh.boolean.available_engines -# if we have all_dep set it means we should test all engines +# test all engines if all_dep is set if g.all_dependencies: engines = g.trimesh.boolean.all_engines @@ -86,54 +86,58 @@ def test_empty(): def test_boolean_manifold(): 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 = [ - 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), + 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 = manifold.boolean(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(): @@ -145,7 +149,7 @@ def both(operation, items): Run our cascaded reduce and regular reduce. """ - b = g.trimesh.boolean.reduce_cascade(operation, items) + b = g.trimesh.util.reduce_cascade(operation, items) if len(items) > 0: assert b == reduce(operation, items) diff --git a/trimesh/boolean.py b/trimesh/boolean.py index bdc60e5f6..2adb932bd 100644 --- a/trimesh/boolean.py +++ b/trimesh/boolean.py @@ -4,10 +4,10 @@ Do boolean operations on meshes using either Blender or Manifold. """ -import numpy as np -from . import interfaces -from .typed import Callable, NDArray, Optional, Sequence, Union +# for dynamic module imports +from importlib import import_module +from .typed import Optional, Sequence def difference( @@ -99,92 +99,24 @@ def intersection( return _engines[engine](meshes, operation="intersection", **kwargs) -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] - - # supported backend boolean engines -# preferred engines are at the top -_backends = [ - interfaces.manifold, - interfaces.blender, +# ordered by preference +all_engines = [ + "manifold", + "blender", ] # test which engines are actually usable _engines = {} -available_engines = () -all_engines = () -for e in _backends: - all_engines = all_engines + e.name - if e.exists: - _engines[e.name] = e.boolean - available_engines = available_engines + e.name +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] = e.boolean + _engines[None] = engine.boolean def set_default_engine(engine): if engine not in available_engines: diff --git a/trimesh/interfaces/__init__.py b/trimesh/interfaces/__init__.py index 7e8848afd..bcdaab741 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", "manifold"] +__all__ = ["gmsh"] diff --git a/trimesh/interfaces/blender.py b/trimesh/interfaces/blender.py index 495dde340..169686ab6 100644 --- a/trimesh/interfaces/blender.py +++ b/trimesh/interfaces/blender.py @@ -6,8 +6,6 @@ from ..typed import Iterable from .generic import MeshScript -name = "blender" - if platform.system() == "Windows": # try to find Blender install on Windows # split existing path by delimiter diff --git a/trimesh/interfaces/manifold.py b/trimesh/interfaces/manifold.py index f52770297..dbc442cf2 100644 --- a/trimesh/interfaces/manifold.py +++ b/trimesh/interfaces/manifold.py @@ -1,10 +1,9 @@ import numpy as np from .. import exceptions -from ..boolean import reduce_cascade from ..typed import Sequence +from ..util import reduce_cascade -name = "manifold" exists = False try: diff --git a/trimesh/path/polygons.py b/trimesh/path/polygons.py index e481dd57b..2636e35a9 100644 --- a/trimesh/path/polygons.py +++ b/trimesh/path/polygons.py @@ -3,7 +3,7 @@ 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 ab452e695..76ace2652 100644 --- a/trimesh/util.py +++ b/trimesh/util.py @@ -23,7 +23,7 @@ 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] From 8e51445d57a0338c9f9acf3a39f6a05d2c8226ac Mon Sep 17 00:00:00 2001 From: Gregor Riepl Date: Sun, 27 Oct 2024 12:43:52 +0100 Subject: [PATCH 04/14] Fix default engine assignment --- trimesh/boolean.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/trimesh/boolean.py b/trimesh/boolean.py index 2adb932bd..82b54ffe0 100644 --- a/trimesh/boolean.py +++ b/trimesh/boolean.py @@ -121,4 +121,4 @@ def intersection( def set_default_engine(engine): if engine not in available_engines: raise ValueError(f"Engine {engine} is not available on this system") - _engines[None] = available_engines[engine].boolean + _engines[None] = _engines[engine] From 8a65b0b6fb55b247485e9bedb1d507c59f6859a5 Mon Sep 17 00:00:00 2001 From: Michael Dawson-Haggerty Date: Tue, 29 Oct 2024 15:29:37 -0400 Subject: [PATCH 05/14] xaml is implicit meters --- trimesh/exchange/xaml.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/trimesh/exchange/xaml.py b/trimesh/exchange/xaml.py index 20a339267..a988a5188 100644 --- a/trimesh/exchange/xaml.py +++ b/trimesh/exchange/xaml.py @@ -136,7 +136,7 @@ def element_to_transform(element): normals.append(c_normals) # compile the results into clean numpy arrays - result = {} + result = {"units": "meters"} result["vertices"], result["faces"] = util.append_faces(vertices, faces) result["face_colors"] = np.vstack(colors) result["vertex_normals"] = np.vstack(normals) From beaf6bb481dee5d4b3a692f8347d955c60ec66ff Mon Sep 17 00:00:00 2001 From: Michael Dawson-Haggerty Date: Tue, 29 Oct 2024 15:30:53 -0400 Subject: [PATCH 06/14] fix #2311 --- trimesh/viewer/widget.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/trimesh/viewer/widget.py b/trimesh/viewer/widget.py index a6e8c9093..3636a3da2 100644 --- a/trimesh/viewer/widget.py +++ b/trimesh/viewer/widget.py @@ -14,7 +14,7 @@ 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 @@ def on_mouse_scroll(self, x, y, dx, dy): 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"): From dfad924b48296c64433321ef4473906c7f81f5ad Mon Sep 17 00:00:00 2001 From: Michael Dawson-Haggerty Date: Thu, 31 Oct 2024 15:22:28 -0400 Subject: [PATCH 07/14] add trimesh.iteration --- trimesh/boolean.py | 2 + trimesh/interfaces/manifold.py | 1 + trimesh/iteration.py | 125 +++++++++++++++++++++++++++++++++ trimesh/path/polygons.py | 2 +- trimesh/util.py | 124 +------------------------------- 5 files changed, 132 insertions(+), 122 deletions(-) create mode 100644 trimesh/iteration.py diff --git a/trimesh/boolean.py b/trimesh/boolean.py index 82b54ffe0..3680e35c2 100644 --- a/trimesh/boolean.py +++ b/trimesh/boolean.py @@ -7,6 +7,7 @@ # for dynamic module imports from importlib import import_module + from .typed import Optional, Sequence @@ -118,6 +119,7 @@ def intersection( # 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") diff --git a/trimesh/interfaces/manifold.py b/trimesh/interfaces/manifold.py index dbc442cf2..553290017 100644 --- a/trimesh/interfaces/manifold.py +++ b/trimesh/interfaces/manifold.py @@ -8,6 +8,7 @@ try: from manifold3d import Manifold, Mesh + exists = True except BaseException as E: Mesh = exceptions.ExceptionWrapper(E) diff --git a/trimesh/iteration.py b/trimesh/iteration.py new file mode 100644 index 000000000..5994341fe --- /dev/null +++ b/trimesh/iteration.py @@ -0,0 +1,125 @@ +import numpy as np + +from .typed import Any, Callable, Iterable, List, NDArray, Sequence, Union + + +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] + + +def chain(*args: Union[Iterable[Any], Any, None]) -> List[Any]: + """ + A less principled version of `list(itertools.chain(*args))` that + accepts non-iterable values, filters `None`, and returns a list + rather than yielding values. + + If all passed values are iterables this will return identical + results to `list(itertools.chain(*args))`. + + + Examples + ---------- + + In [1]: list(itertools.chain([1,2], [3])) + Out[1]: [1, 2, 3] + + In [2]: trimesh.util.chain([1,2], [3]) + Out[2]: [1, 2, 3] + + In [3]: trimesh.util.chain([1,2], [3], 4) + Out[3]: [1, 2, 3, 4] + + In [4]: list(itertools.chain([1,2], [3], 4)) + ----> 1 list(itertools.chain([1,2], [3], 4)) + TypeError: 'int' object is not iterable + + In [5]: trimesh.util.chain([1,2], None, 3, None, [4], [], [], 5, []) + Out[5]: [1, 2, 3, 4, 5] + + + Parameters + ----------- + args + Will be individually checked to see if they're iterable + before either being appended or extended to a flat list. + + + Returns + ---------- + chained + The values in a flat list. + """ + # collect values to a flat list + chained = [] + # extend if it's a sequence, otherwise append + [ + chained.extend(a) + if (hasattr(a, "__iter__") and not isinstance(a, str)) + else chained.append(a) + for a in args + if a is not None + ] + return chained diff --git a/trimesh/path/polygons.py b/trimesh/path/polygons.py index 2636e35a9..0d7064b1c 100644 --- a/trimesh/path/polygons.py +++ b/trimesh/path/polygons.py @@ -3,11 +3,11 @@ from shapely.geometry import Polygon from .. import bounds, geometry, graph, grouping -from ..util import reduce_cascade from ..constants import log from ..constants import tol_path as tol from ..transformations import transform_points from ..typed import Iterable, NDArray, Number, Optional, Union, float64, int64 +from ..util import reduce_cascade from .simplify import fit_circle_check from .traversal import resample_path diff --git a/trimesh/util.py b/trimesh/util.py index 76ace2652..66ac4775d 100644 --- a/trimesh/util.py +++ b/trimesh/util.py @@ -22,8 +22,10 @@ import numpy as np +from .iteration import chain + # use our wrapped types for wider version compatibility -from .typed import Any, Iterable, List, Union, Callable, NDArray, Optional, Sequence +from .typed import Union # create a default logger log = logging.getLogger("trimesh") @@ -1384,59 +1386,6 @@ class : Optional[Callable] raise ValueError("Unable to extract class of name " + name) -def chain(*args: Union[Iterable[Any], Any, None]) -> List[Any]: - """ - A less principled version of `list(itertools.chain(*args))` that - accepts non-iterable values, filters `None`, and returns a list - rather than yielding values. - - If all passed values are iterables this will return identical - results to `list(itertools.chain(*args))`. - - - Examples - ---------- - - In [1]: list(itertools.chain([1,2], [3])) - Out[1]: [1, 2, 3] - - In [2]: trimesh.util.chain([1,2], [3]) - Out[2]: [1, 2, 3] - - In [3]: trimesh.util.chain([1,2], [3], 4) - Out[3]: [1, 2, 3, 4] - - In [4]: list(itertools.chain([1,2], [3], 4)) - ----> 1 list(itertools.chain([1,2], [3], 4)) - TypeError: 'int' object is not iterable - - In [5]: trimesh.util.chain([1,2], None, 3, None, [4], [], [], 5, []) - Out[5]: [1, 2, 3, 4, 5] - - - Parameters - ----------- - args - Will be individually checked to see if they're iterable - before either being appended or extended to a flat list. - - - Returns - ---------- - chained - The values in a flat list. - """ - # collect values to a flat list - chained = [] - # extend if it's a sequence, otherwise append - [ - chained.extend(a) if is_sequence(a) else chained.append(a) - for a in args - if a is not None - ] - return chained - - def concatenate( a, b=None ) -> Union["trimesh.Trimesh", "trimesh.path.Path2D", "trimesh.path.Path3D"]: # noqa: F821 @@ -2473,70 +2422,3 @@ 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] From 9f069a8fa9e3dc8b9251e2b1ca40f6b98e43b409 Mon Sep 17 00:00:00 2001 From: Michael Dawson-Haggerty Date: Thu, 31 Oct 2024 15:29:34 -0400 Subject: [PATCH 08/14] add simple default engine choosing --- pyproject.toml | 2 +- tests/test_boolean.py | 2 - trimesh/__init__.py | 2 + trimesh/boolean.py | 115 +++++++++++++++++++++++++-------- trimesh/interfaces/__init__.py | 2 +- trimesh/interfaces/manifold.py | 77 ---------------------- trimesh/path/polygons.py | 2 +- 7 files changed, 94 insertions(+), 108 deletions(-) delete mode 100644 trimesh/interfaces/manifold.py diff --git a/pyproject.toml b/pyproject.toml index 6a906c4e5..6d2093237 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.5.1" +version = "4.5.2" 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/tests/test_boolean.py b/tests/test_boolean.py index 007790d95..4843350ac 100644 --- a/tests/test_boolean.py +++ b/tests/test_boolean.py @@ -5,7 +5,6 @@ import numpy as np - # test only available engines by default engines = g.trimesh.boolean.available_engines # test all engines if all_dep is set @@ -89,7 +88,6 @@ def test_boolean_manifold(): # run this test only when manifold3d is available when # all_dep is enabled if manifold.exists or g.all_dependencies: - times = {} for operation in ["union", "intersection"]: if operation == "union": diff --git a/trimesh/__init__.py b/trimesh/__init__.py index 00ebec5d2..0742d68f5 100644 --- a/trimesh/__init__.py +++ b/trimesh/__init__.py @@ -25,6 +25,7 @@ grouping, inertia, intersections, + iteration, nsphere, permutate, poses, @@ -102,6 +103,7 @@ "graph", "grouping", "inertia", + "iteration", "intersections", "load", "load_mesh", diff --git a/trimesh/boolean.py b/trimesh/boolean.py index 3680e35c2..b1e1b0696 100644 --- a/trimesh/boolean.py +++ b/trimesh/boolean.py @@ -5,11 +5,19 @@ Do boolean operations on meshes using either Blender or Manifold. """ -# for dynamic module imports -from importlib import import_module +import numpy as np +from . import interfaces +from .exceptions import ExceptionWrapper +from .iteration import reduce_cascade from .typed import Optional, Sequence +try: + from manifold3d import Manifold, Mesh +except BaseException as E: + Mesh = ExceptionWrapper(E) + Manifold = ExceptionWrapper(E) + def difference( meshes: Sequence, engine: Optional[str] = None, check_volume: bool = True, **kwargs @@ -100,27 +108,82 @@ def intersection( return _engines[engine](meshes, operation="intersection", **kwargs) -# 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] +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) + + +# which backend boolean engines +_engines = { + "manifold": boolean_manifold, + "blender": interfaces.blender.boolean, +} + +if not isinstance(boolean_manifold, ExceptionWrapper): + # if available, manifold3d is the preferred option + _engines[None] = boolean_manifold +elif interfaces.blender.boolean.exists: + # otherwise we can call blender with subprocess + _engines[None] = interfaces.blender.boolean +else: + # failing that add a helpful error message + _engines[None] = ExceptionWrapper( + ImportError("No boolean backend! `pip install manifold3d` or install blender") + ) diff --git a/trimesh/interfaces/__init__.py b/trimesh/interfaces/__init__.py index bcdaab741..45416ec8b 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__ = ["gmsh"] +__all__ = ["gmsh", "blender"] diff --git a/trimesh/interfaces/manifold.py b/trimesh/interfaces/manifold.py deleted file mode 100644 index 553290017..000000000 --- a/trimesh/interfaces/manifold.py +++ /dev/null @@ -1,77 +0,0 @@ -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 0d7064b1c..7fa793dba 100644 --- a/trimesh/path/polygons.py +++ b/trimesh/path/polygons.py @@ -5,9 +5,9 @@ from .. import bounds, geometry, graph, grouping from ..constants import log from ..constants import tol_path as tol +from ..iteration import reduce_cascade from ..transformations import transform_points from ..typed import Iterable, NDArray, Number, Optional, Union, float64, int64 -from ..util import reduce_cascade from .simplify import fit_circle_check from .traversal import resample_path From 9f41e2c500fba7e118c8105c05c8f61b11564c40 Mon Sep 17 00:00:00 2001 From: Michael Dawson-Haggerty Date: Thu, 31 Oct 2024 15:53:50 -0400 Subject: [PATCH 09/14] use more explicit import chain --- tests/test_boolean.py | 25 ++++++++++++++----------- trimesh/boolean.py | 38 +++++++++++++++++++++++--------------- 2 files changed, 37 insertions(+), 26 deletions(-) diff --git a/tests/test_boolean.py b/tests/test_boolean.py index 4843350ac..9b21c6d48 100644 --- a/tests/test_boolean.py +++ b/tests/test_boolean.py @@ -6,10 +6,10 @@ import numpy as np # test only available engines by default -engines = g.trimesh.boolean.available_engines +engines = g.trimesh.boolean._engines.keys() # test all engines if all_dep is set if g.all_dependencies: - engines = g.trimesh.boolean.all_engines + engines = g.trimesh.boolean._engines.keys() def test_boolean(): @@ -83,12 +83,15 @@ def test_empty(): def test_boolean_manifold(): - from trimesh.interfaces import manifold + from trimesh.boolean import _engines, boolean_manifold + + times = {} + exists = not isinstance(_engines["manifold"], g.trimesh.exceptions.ExceptionWrapper) + + # run this test only when manifold3d is available or `all_dep` is enabled + if exists or g.all_dependencies: + import manifold3d - # run this test only when manifold3d is available when - # all_dep is enabled - if manifold.exists or g.all_dependencies: - times = {} for operation in ["union", "intersection"]: if operation == "union": # chain of icospheres @@ -106,8 +109,8 @@ def test_boolean_manifold(): # the old 'serial' manifold method tic = g.time.time() manifolds = [ - manifold.manifold3d.Manifold( - mesh=manifold.manifold3d.Mesh( + manifold3d.Manifold( + mesh=manifold3d.Mesh( vert_properties=np.array(mesh.vertices, dtype=np.float32), tri_verts=np.array(mesh.faces, dtype=np.uint32), ) @@ -128,7 +131,7 @@ def test_boolean_manifold(): # new 'binary' method tic = g.time.time() - new_mesh = manifold.boolean(meshes, operation) + new_mesh = boolean_manifold(meshes, operation) times["binary " + operation] = g.time.time() - tic assert old_mesh.is_volume == new_mesh.is_volume @@ -147,7 +150,7 @@ def both(operation, items): Run our cascaded reduce and regular reduce. """ - b = g.trimesh.util.reduce_cascade(operation, items) + b = g.trimesh.iteration.reduce_cascade(operation, items) if len(items) > 0: assert b == reduce(operation, items) diff --git a/trimesh/boolean.py b/trimesh/boolean.py index b1e1b0696..ce13ccb23 100644 --- a/trimesh/boolean.py +++ b/trimesh/boolean.py @@ -10,7 +10,7 @@ from . import interfaces from .exceptions import ExceptionWrapper from .iteration import reduce_cascade -from .typed import Optional, Sequence +from .typed import Callable, Dict, Optional, Sequence try: from manifold3d import Manifold, Mesh @@ -170,20 +170,28 @@ def boolean_manifold( return Trimesh(vertices=result_mesh.vert_properties, faces=result_mesh.tri_verts) -# which backend boolean engines -_engines = { - "manifold": boolean_manifold, - "blender": interfaces.blender.boolean, -} +# which backend boolean engines do we have +_engines: Dict[str, Callable] = {} -if not isinstance(boolean_manifold, ExceptionWrapper): - # if available, manifold3d is the preferred option - _engines[None] = boolean_manifold -elif interfaces.blender.boolean.exists: - # otherwise we can call blender with subprocess - _engines[None] = interfaces.blender.boolean +if isinstance(Manifold, ExceptionWrapper): + # manifold isn't available so use the import error + _engines["manifold"] = Manifold +else: + # manifold3d is the preferred option + _engines["manifold"] = boolean_manifold + + +if interfaces.blender.exists: + # we have `blender` in the path which we can call with subprocess + _engines["blender"] = interfaces.blender.boolean else: # failing that add a helpful error message - _engines[None] = ExceptionWrapper( - ImportError("No boolean backend! `pip install manifold3d` or install blender") - ) + _engines["blender"] = ExceptionWrapper(ImportError("`blender` is not in `PATH`")) + +# pick the first value that isn't an ExceptionWrapper. +_engines[None] = next( + (v for v in _engines.values() if not isinstance(v, ExceptionWrapper)), + ExceptionWrapper( + ImportError("No boolean backend: `pip install manifold3d` or install `blender`") + ), +) From ea74529565f1158c490cf647f6ecc778e3bd6717 Mon Sep 17 00:00:00 2001 From: Michael Dawson-Haggerty Date: Thu, 31 Oct 2024 19:18:28 -0400 Subject: [PATCH 10/14] add engines_available --- tests/test_boolean.py | 2 +- trimesh/boolean.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_boolean.py b/tests/test_boolean.py index 9b21c6d48..d6439d208 100644 --- a/tests/test_boolean.py +++ b/tests/test_boolean.py @@ -6,7 +6,7 @@ import numpy as np # test only available engines by default -engines = g.trimesh.boolean._engines.keys() +engines = g.trimesh.boolean.engines_available # test all engines if all_dep is set if g.all_dependencies: engines = g.trimesh.boolean._engines.keys() diff --git a/trimesh/boolean.py b/trimesh/boolean.py index ce13ccb23..4c113f577 100644 --- a/trimesh/boolean.py +++ b/trimesh/boolean.py @@ -195,3 +195,7 @@ def boolean_manifold( ImportError("No boolean backend: `pip install manifold3d` or install `blender`") ), ) + +engines_available = { + k for k, v in _engines.items() if not isinstance(v, ExceptionWrapper) +} From e78bd6066d90b7d331bc7e47373ae8586cad0de5 Mon Sep 17 00:00:00 2001 From: Michael Dawson-Haggerty Date: Thu, 31 Oct 2024 19:31:00 -0400 Subject: [PATCH 11/14] was blender not being tested --- docker/trimesh-setup | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docker/trimesh-setup b/docker/trimesh-setup index ba614dd18..01c94bda4 100755 --- a/docker/trimesh-setup +++ b/docker/trimesh-setup @@ -43,7 +43,8 @@ config_json = """ ], "test": [ "curl", - "git" + "git", + "blender", ], "gmsh": ["libxft2", "libxinerama-dev", "libxcursor1","libgomp1"] }, From 86976795aab9ed8c8db4a6d9c005feaea47e0ff6 Mon Sep 17 00:00:00 2001 From: Michael Dawson-Haggerty Date: Thu, 31 Oct 2024 19:55:41 -0400 Subject: [PATCH 12/14] json typo --- docker/trimesh-setup | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/trimesh-setup b/docker/trimesh-setup index 01c94bda4..f815fe028 100755 --- a/docker/trimesh-setup +++ b/docker/trimesh-setup @@ -44,8 +44,8 @@ config_json = """ "test": [ "curl", "git", - "blender", - ], + "blender" +], "gmsh": ["libxft2", "libxinerama-dev", "libxcursor1","libgomp1"] }, "fetch": { From 740ca2bd23f043a6ac767bcbfb15d111083e110f Mon Sep 17 00:00:00 2001 From: Michael Dawson-Haggerty Date: Fri, 1 Nov 2024 15:56:50 -0400 Subject: [PATCH 13/14] install blender from tarball --- Dockerfile | 2 +- docker/trimesh-setup | 137 ++++++++++++++++++++++--------------------- 2 files changed, 72 insertions(+), 67 deletions(-) diff --git a/Dockerfile b/Dockerfile index f134a1bfd..a7dab5248 100644 --- a/Dockerfile +++ b/Dockerfile @@ -61,7 +61,7 @@ COPY --chown=499 pyproject.toml . COPY --chown=499 ./.git ./.git/ USER root -RUN trimesh-setup --install=test,gmsh,gltf_validator,llvmpipe,binvox +RUN trimesh-setup --install=test,gmsh,gltf_validator,llvmpipe,binvox,blender USER user # install things like pytest and make sure we're on Numpy 2.X diff --git a/docker/trimesh-setup b/docker/trimesh-setup index f815fe028..142982b54 100755 --- a/docker/trimesh-setup +++ b/docker/trimesh-setup @@ -6,15 +6,14 @@ environment for `trimesh` in a Debian Docker image. It probably isn't useful for most people unless you are running this exact configuration. """ + import argparse import json import logging import os -import shutil import subprocess import sys import tarfile -import tempfile from fnmatch import fnmatch from io import BytesIO @@ -43,8 +42,7 @@ config_json = """ ], "test": [ "curl", - "git", - "blender" + "git" ], "gmsh": ["libxft2", "libxinerama-dev", "libxcursor1","libgomp1"] }, @@ -53,21 +51,31 @@ config_json = """ "url": "https://github.com/KhronosGroup/glTF-Validator/releases/download/2.0.0-dev.3.8/gltf_validator-2.0.0-dev.3.8-linux64.tar.xz", "sha256": "374c7807e28fe481b5075f3bb271f580ddfc0af3e930a0449be94ec2c1f6f49a", "target": "$PATH", - "chmod": 755, + "chmod": {"gltf_validator": 755}, "extract_only": "gltf_validator" }, "pandoc": { "url": "https://github.com/jgm/pandoc/releases/download/3.1.1/pandoc-3.1.1-linux-amd64.tar.gz", "sha256": "52b25f0115517e32047a06d821e63729108027bd06d9605fe8eac0fa83e0bf81", "target": "$PATH", - "chmod": 755, + "chmod": {"pandoc": 755}, "extract_only": "pandoc" }, + "binvox": { "url": "https://trimesh.s3-us-west-1.amazonaws.com/binvox", "sha256": "82ee314a75986f67f1d2b5b3ccdfb3661fe57a6b428aa0e0f798fdb3e1734fe0", "target": "$PATH", - "chmod": 755 + "chmod": {"binvox": 755} + }, + + "blender": { + "url": "https://mirrors.ocf.berkeley.edu/blender/release/Blender4.2/blender-4.2.3-linux-x64.tar.xz", + "sha256": "3a64efd1982465395abab4259b4091d5c8c56054c7267e9633e4f702a71ea3f4", + "target": "$PATH", + "chmod": {"blender": 755}, + "strip_components": 1 + } } } @@ -155,7 +163,22 @@ def fetch(url, sha256): return data -def copy_to_path(file_path, prefix="~"): +def is_writable(path: str) -> bool: + if not os.path.isdir(path): + return False + + test_fn = os.path.join(path, ".test_writeable_file") + try: + with open(test_fn, "w") as f: + f.write("can we write here?") + os.remove(test_fn) + return True + except BaseException as E: + print(path, E) + return False + + +def choose_in_path(prefix="~") -> str: """ Copy an executable file onto `PATH`, typically one of the options in the current user's home directory. @@ -168,18 +191,6 @@ def copy_to_path(file_path, prefix="~"): The path prefix it is acceptable to copy into, typically `~` for `/home/{current_user}`. """ - # get the full path of the requested file - source = os.path.abspath(os.path.expanduser(file_path)) - - # get the file name - file_name = os.path.split(source)[-1] - - # make sure the source file is readable and not empty - with open(source, "rb") as f: - file_data = f.read() - # check for empty files - if len(file_data) == 0: - raise ValueError(f"empty file: {file_path}") # get all locations in PATH candidates = [ @@ -203,13 +214,9 @@ def copy_to_path(file_path, prefix="~"): # try writing to the shortest paths first for index in argsort(scores): - path = os.path.join(candidates[index], file_name) - try: - shutil.copy(source, path) - print(f"wrote `{path}`") + path = candidates[index] + if is_writable(path): return path - except BaseException: - pass # none of our candidates worked raise ValueError("unable to write to file") @@ -260,17 +267,31 @@ def handle_fetch( A hex string for the hash of the remote resource. target : str Target location on the local file system. - chmod : None or int. + chmod : None or dict Change permissions for extracted files. extract_skip : None or iterable - Skip a certain member of the archive. - extract_only : None or str - Extract *only* a single file from the archive, - overrides `extract_skip`. + Skip a certain member of the archive using + an `fnmatch` pattern, i.e. "lib/*" + extract_only : None or iterable + Extract only whitelisted files from the archive + using an `fnmatch` pattern, i.e. "lib/*" strip_components : int Strip off this many components from the file path in the archive, i.e. at `1`, `a/b/c` is extracted to `target/b/c` """ + if target.lower().strip() == "$path": + target = choose_in_path() + log.debug(f"identified destination as `{target}`") + + if chmod is None: + chmod = {} + + if extract_skip is None: + extract_skip = [] + # if passed a single string + if isinstance(extract_only, str): + extract_only = [extract_only] + # get the raw bytes log.debug(f"fetching: `{url}`") raw = fetch(url=url, sha256=sha256) @@ -285,10 +306,10 @@ def handle_fetch( # get the archive tar = tarfile.open(fileobj=BytesIO(raw), mode=mode) - if extract_skip is None: - extract_skip = [] - for member in tar.getmembers(): + if member.isdir(): + continue + # final name after stripping components name = "/".join(member.name.split("/")[strip_components:]) @@ -297,44 +318,28 @@ def handle_fetch( log.debug(f"skipping: `{name}`") continue - if extract_only is None: - path = os.path.join(target, name) - log.debug(f"extracting: `{path}`") - extract(tar=tar, member=member, path=path, chmod=chmod) - else: - name = name.split("/")[-1] - if name == extract_only: - if target.lower() == "$path": - with tempfile.TemporaryDirectory() as D: - path = os.path.join(D, name) - log.debug(f"extracting `{path}`") - extract(tar=tar, member=member, path=path, chmod=chmod) - copy_to_path(path) - return - - path = os.path.join(target, name) - log.debug(f"extracting `{path}`") - extract(tar=tar, member=member, path=path, chmod=chmod) - return + if extract_only is not None and not any( + fnmatch(name, p) for p in extract_only + ): + log.debug(f"skipping: `{name}`") + continue + + path = os.path.join(target, name) + log.debug(f"extracting: `{path}`") + extract(tar=tar, member=member, path=path, chmod=chmod.get(name, None)) + else: # a single file name = url.split("/")[-1].strip() - if target.lower() == "$path": - with tempfile.TemporaryDirectory() as D: - temp_path = os.path.join(D, name) - with open(temp_path, "wb") as f: - f.write(raw) - # move the file somewhere on the path - path = copy_to_path(temp_path) - else: - path = target - with open(path, "wb") as f: - f.write(raw) + path = os.path.join(target, name) + with open(path, "wb") as f: + f.write(raw) + current = chmod.get(name, None) # apply chmod if requested - if chmod is not None: + if current is not None: # python os.chmod takes an octal value - os.chmod(path, int(str(chmod), base=8)) + os.chmod(path, int(str(current), base=8)) def load_config(): From 6a1162509bff3e44fd370d1c3e94d6f2592fed6d Mon Sep 17 00:00:00 2001 From: Michael Dawson-Haggerty Date: Fri, 1 Nov 2024 16:31:56 -0400 Subject: [PATCH 14/14] oh blender --- Dockerfile | 2 ++ docker/trimesh-setup | 8 ++++---- pyproject.toml | 6 ++++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index a7dab5248..f864e423c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -64,6 +64,8 @@ USER root RUN trimesh-setup --install=test,gmsh,gltf_validator,llvmpipe,binvox,blender USER user +RUN blender --version + # install things like pytest and make sure we're on Numpy 2.X RUN pip install .[all] && \ python -c "import numpy as n; assert(n.__version__.startswith('2'))" diff --git a/docker/trimesh-setup b/docker/trimesh-setup index 142982b54..f66977d7c 100755 --- a/docker/trimesh-setup +++ b/docker/trimesh-setup @@ -42,7 +42,8 @@ config_json = """ ], "test": [ "curl", - "git" + "git", + "libxkbcommon0" ], "gmsh": ["libxft2", "libxinerama-dev", "libxcursor1","libgomp1"] }, @@ -75,7 +76,6 @@ config_json = """ "target": "$PATH", "chmod": {"blender": 755}, "strip_components": 1 - } } } @@ -363,8 +363,8 @@ if __name__ == "__main__": # collect `apt-get install`-able package apt_select = [] handlers = { - "fetch": lambda x: handle_fetch(**x), "apt": lambda x: apt_select.extend(x), + "fetch": lambda x: handle_fetch(**x), } # allow comma delimiters and de-duplicate @@ -372,7 +372,7 @@ if __name__ == "__main__": parser.print_help() exit() else: - select = set(" ".join(args.install).replace(",", " ").split()) + select = " ".join(args.install).replace(",", " ").split() log.debug(f'installing metapackages: `{", ".join(select)}`') diff --git a/pyproject.toml b/pyproject.toml index 6d2093237..b385a4929 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,10 +24,12 @@ classifiers = [ "Topic :: Multimedia :: Graphics", "Topic :: Multimedia :: Graphics :: 3D Modeling" ] -urls = {Homepage = "https://github.com/mikedh/trimesh"} - dependencies = ["numpy>=1.20"] +[project.urls] +homepage = "https://github.com/mikedh/trimesh" +documentation = "https://trimesh.org" + [project.readme] file = "README.md" content-type = "text/markdown"