diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 633aa0bf1..2a5bb55d1 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -51,35 +51,6 @@ jobs: with: ref: ${{ github.ref }} - - name: Install non-Python dependencies (Linux) - if: ${{ matrix.os == 'ubuntu-latest' }} - uses: awalsh128/cache-apt-pkgs-action@latest - with: - packages: blender openscad - - - name: Restore cached non-Python dependencies (Windows) - id: windows-cache-deps - if: ${{ matrix.os == 'windows-latest' }} - uses: actions/cache@v3 - with: - path: downloads - key: windows-deps - - - name: Download non-Python dependencies (Windows) - if: ${{ matrix.os == 'windows-latest' && steps.windows-cache-deps.outputs.cache-hit != 'true' }} - run: | - New-Item -Path downloads -ItemType Directory -Force - Invoke-WebRequest https://github.com/openscad/openscad/releases/download/openscad-2021.01/OpenSCAD-2021.01-x86-64.zip -O downloads/openscad.zip - Invoke-WebRequest https://download.blender.org/release/Blender3.6/blender-3.6.0-windows-x64.zip -O downloads/blender.zip - - - name: Install non-Python dependencies (Windows) - if: ${{ matrix.os == 'windows-latest' }} - run: | - Expand-Archive -Path downloads/openscad.zip -DestinationPath openscad - Move-Item -Path openscad/openscad-2021.01 -Destination $Env:Programfiles\OpenSCAD - Expand-Archive -Path downloads/blender.zip -DestinationPath blender - Move-Item -Path blender/blender-3.6.0-windows-x64 -Destination "$Env:Programfiles\Blender Foundation\Blender" - - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: diff --git a/docs/_templates/installation.rst b/docs/_templates/installation.rst index daedbf981..bd1af936f 100644 --- a/docs/_templates/installation.rst +++ b/docs/_templates/installation.rst @@ -1,4 +1,4 @@ -Next, activate the `virtual environment `_ in which you want to install Scenic. +Activate the `virtual environment `_ in which you want to install Scenic. To create and activate a new virtual environment called :file:`venv`, you can run the following commands: .. venv-setup-start diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 2fe5bf7b5..34fe517d0 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -25,20 +25,16 @@ If you encounter any errors, please see our :doc:`install_notes` for suggestions .. tab:: macOS - Start by downloading `Blender `__ and `OpenSCAD `__ and installing them into your :file:`Applications` directory. - .. include:: _templates/installation.rst .. tab:: Linux - Start by installing the Python-Tk interface, Blender, and OpenSCAD. + Start by installing the Python-Tk interface. You can likely use your system's package manager; e.g. on Debian/Ubuntu run: .. code-block:: text - sudo apt-get install python3-tk blender openscad - - For other Linux distributions or if you need to install from source, see the download pages for `Blender `__ and `OpenSCAD `__. + sudo apt-get install python3-tk .. include:: _templates/installation.rst @@ -46,8 +42,6 @@ If you encounter any errors, please see our :doc:`install_notes` for suggestions These instructions cover installing Scenic natively on Windows; if you are using the `Windows Subsystem for Linux `_ (on Windows 10 and newer), see the WSL tab instead. - Start by downloading and running the installers for `Blender `__ and `OpenSCAD `__. - .. include:: _templates/installation.rst :end-before: .. venv-setup-start @@ -64,12 +58,12 @@ If you encounter any errors, please see our :doc:`install_notes` for suggestions These instructions cover installing Scenic on the Windows Subsystem for Linux (WSL). If you haven't already installed WSL, you can do that by running :command:`wsl --install` (in either Command Prompt or PowerShell) and restarting your computer. - Then open a WSL terminal and run the following commands to install Python, the Python-Tk interface, Blender, and OpenSCAD: + Then open a WSL terminal and run the following commands to install Python and the Python-Tk interface: .. code-block:: text sudo apt-get update - sudo apt-get install python3 python3-tk blender openscad + sudo apt-get install python3 python3-tk .. include:: _templates/installation.rst :end-before: .. venv-setup-start diff --git a/docs/reference/region_types.rst b/docs/reference/region_types.rst index ffe44d1aa..76507693c 100644 --- a/docs/reference/region_types.rst +++ b/docs/reference/region_types.rst @@ -78,8 +78,6 @@ When checking containment of an `Object` in a 2D region, Scenic will atuomatical Most 3D regions inherit from either `MeshVolumeRegion` or `MeshSurfaceRegion`, which represent the volume (of a watertight mesh) and the surface of a mesh respectively. Various region classes are also provided to create primitive shapes. `MeshVolumeRegion` can be converted to `MeshSurfaceRegion` (and vice versa) using the the ``getSurfaceRegion`` and ``getVolumeRegion`` methods. -Mesh regions can use one of two engines for mesh operations: Blender or OpenSCAD. This can be controlled using the ``engine`` parameter, passing ``"blender"`` or ``"scad"`` respectively. Blender is generally more tolerant but can produce unreliable output, such as meshes that have microscopic holes. OpenSCAD is generally more precise, but may crash on certain inputs that it considers ill-defined. By default, Scenic uses Blender internally. - PolygonalFootprintRegions represent the :term:`footprint` of a 2D region. See `2D Regions` for more details. .. autoclass:: scenic.core.regions.MeshVolumeRegion diff --git a/pyproject.toml b/pyproject.toml index 89ae98ce9..8db98f8e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ dependencies = [ "dotmap ~= 1.3", "mapbox_earcut >= 0.12.10", "matplotlib ~= 3.2", + "manifold3d == 2.3.0", "networkx >= 2.6", "numpy ~= 1.24", "opencv-python ~= 4.5", @@ -46,7 +47,7 @@ dependencies = [ "scikit-image ~= 0.21", "scipy ~= 1.7", "shapely ~= 2.0", - "trimesh >=3.22.5, <4", + "trimesh >=4.0.9, <5", ] [project.optional-dependencies] diff --git a/src/scenic/core/regions.py b/src/scenic/core/regions.py index 417b50cfd..c75525e83 100644 --- a/src/scenic/core/regions.py +++ b/src/scenic/core/regions.py @@ -767,7 +767,6 @@ class MeshRegion(Region): tolerance: Tolerance for internal computations. centerMesh: Whether or not to center the mesh after copying and before transformations. onDirection: The direction to use if an object being placed on this region doesn't specify one. - engine: Which engine to use for mesh operations. Either "blender" or "scad". additionalDeps: Any additional sampling dependencies this region relies on. """ @@ -781,7 +780,6 @@ def __init__( tolerance=1e-6, centerMesh=True, onDirection=None, - engine="scad", name=None, additionalDeps=[], ): @@ -794,7 +792,6 @@ def __init__( self.tolerance = tolerance self.centerMesh = centerMesh self.onDirection = onDirection - self.engine = engine # Initialize superclass with samplables super().__init__( @@ -812,7 +809,7 @@ def __init__( return # Convert extract mesh - if isinstance(mesh, trimesh.primitives._Primitive): + if isinstance(mesh, trimesh.primitives.Primitive): self._mesh = mesh.to_mesh() elif isinstance(mesh, trimesh.base.Trimesh): self._mesh = mesh.copy() @@ -893,7 +890,6 @@ def sampleGiven(self, value): tolerance=self.tolerance, centerMesh=self.centerMesh, onDirection=self.onDirection, - engine=self.engine, name=self.name, ) @@ -920,7 +916,6 @@ def evaluateInner(self, context): tolerance=self.tolerance, centerMesh=self.centerMesh, onDirection=self.onDirection, - engine=self.engine, name=self.name, ) @@ -1057,7 +1052,6 @@ class MeshVolumeRegion(MeshRegion): tolerance: Tolerance for internal computations. centerMesh: Whether or not to center the mesh after copying and before transformations. onDirection: The direction to use if an object being placed on this region doesn't specify one. - engine: Which engine to use for mesh operations. Either "blender" or "scad". """ def __init__(self, *args, **kwargs): @@ -1418,12 +1412,7 @@ def intersect(self, other, triedReversed=False): other_mesh = other.mesh # Compute intersection using Trimesh - try: - new_mesh = self.mesh.intersection(other_mesh, engine=self.engine) - except ValueError as exc: - raise ValueError( - "Unable to compute mesh boolean operation. Do you have the Blender and OpenSCAD installed on your system?" - ) from exc + new_mesh = self.mesh.intersection(other_mesh) if new_mesh.is_empty: return nowhere @@ -1432,7 +1421,6 @@ def intersect(self, other, triedReversed=False): new_mesh, tolerance=min(self.tolerance, other.tolerance), centerMesh=False, - engine=self.engine, ) else: # Something went wrong, abort @@ -1629,12 +1617,7 @@ def union(self, other, triedReversed=False): other_mesh = other.mesh # Compute union using Trimesh - try: - new_mesh = self.mesh.union(other_mesh, engine=self.engine) - except ValueError as exc: - raise ValueError( - "Unable to compute mesh boolean operation. Do you have the Blender and OpenSCAD installed on your system?" - ) from exc + new_mesh = self.mesh.union(other_mesh) if new_mesh.is_empty: return nowhere @@ -1643,7 +1626,6 @@ def union(self, other, triedReversed=False): new_mesh, tolerance=min(self.tolerance, other.tolerance), centerMesh=False, - engine=self.engine, ) else: # Something went wrong, abort @@ -1669,14 +1651,7 @@ def difference(self, other, debug=False): other_mesh = other.mesh # Compute difference using Trimesh - try: - new_mesh = self.mesh.difference( - other_mesh, engine=self.engine, debug=debug - ) - except ValueError as exc: - raise ValueError( - "Unable to compute mesh boolean operation. Do you have the Blender and OpenSCAD installed on your system?" - ) from exc + new_mesh = self.mesh.difference(other_mesh) if new_mesh.is_empty: return nowhere @@ -1685,7 +1660,6 @@ def difference(self, other, debug=False): new_mesh, tolerance=min(self.tolerance, other.tolerance), centerMesh=False, - engine=self.engine, ) else: # Something went wrong, abort @@ -1765,7 +1739,6 @@ def getSurfaceRegion(self): tolerance=self.tolerance, centerMesh=False, onDirection=self.onDirection, - engine=self.engine, ) def getVolumeRegion(self): @@ -1957,7 +1930,6 @@ def getVolumeRegion(self): tolerance=self.tolerance, centerMesh=False, onDirection=self.onDirection, - engine=self.engine, ) def getSurfaceRegion(self): @@ -1989,7 +1961,6 @@ def sampleGiven(self, value): rotation=value[self.rotation], orientation=value[self.orientation], tolerance=self.tolerance, - engine=self.engine, name=self.name, ) @@ -2005,7 +1976,6 @@ def evaluateInner(self, context): rotation=rotation, orientation=orientation, tolerance=self.tolerance, - engine=self.engine, name=self.name, ) @@ -2034,7 +2004,6 @@ def sampleGiven(self, value): rotation=value[self.rotation], orientation=value[self.orientation], tolerance=self.tolerance, - engine=self.engine, name=self.name, ) @@ -2050,7 +2019,6 @@ def evaluateInner(self, context): rotation=rotation, orientation=orientation, tolerance=self.tolerance, - engine=self.engine, name=self.name, ) @@ -3596,13 +3564,10 @@ class ViewRegion(MeshVolumeRegion): * Case 1: viewAngles[1] = 180 degrees - * Case 2.a viewAngles[0] = 360 degrees => Sphere - * Case 2.b viewAngles[0] < 360 degrees => Sphere & CylinderSectionRegion - - * Case 2: viewAngles[1] < 180 degrees + * Case 1.a viewAngles[0] = 360 degrees => Sphere + * Case 1.b viewAngles[0] < 360 degrees => Sphere & CylinderSectionRegion - * Case 2.a viewAngles[0] = 360 degrees => Sphere - (Cone + Cone) (Cones on z axis expanding from origin) - * Case 2.b viewAngles[0] < 360 degrees => Sphere & ViewSectionRegion + * Case 2: viewAngles[1] < 180 degrees => Sphere & ViewSectionRegion When making changes to this class you should run ``pytest -k test_viewRegion --exhaustive``. @@ -3626,60 +3591,45 @@ def __init__( position=Vector(0, 0, 0), rotation=None, orientation=None, - angleCutoff=0.01, + angleCutoff=0.017, tolerance=1e-8, ): # Bound viewAngles from either side. if min(viewAngles) <= 0: raise ValueError("viewAngles cannot have a component less than or equal to 0") + # TODO True surface representation + viewAngles = (max(viewAngles[0], angleCutoff), max(viewAngles[1], angleCutoff)) + + if math.tau - angleCutoff <= viewAngles[0]: + viewAngles = (math.tau, viewAngles[1]) + + if math.pi - angleCutoff <= viewAngles[1]: + viewAngles = (viewAngles[0], math.pi) + view_region = None diameter = 2 * visibleDistance - base_sphere = SpheroidRegion( - dimensions=(diameter, diameter, diameter), engine="scad" - ) + base_sphere = SpheroidRegion(dimensions=(diameter, diameter, diameter)) if math.pi - angleCutoff <= viewAngles[1]: # Case 1 - if math.tau - angleCutoff <= viewAngles[0]: + if viewAngles[0] == math.tau: # Case 1.a view_region = base_sphere else: + # Case 1.b view_region = base_sphere.intersect( CylinderSectionRegion(visibleDistance, viewAngles[0]) ) else: # Case 2 - if math.tau - angleCutoff <= viewAngles[0]: - # Case 2.a - # Create cone with yaw oriented around (0,0,-1) - padded_height = visibleDistance * 2 - radius = padded_height * math.tan((math.pi - viewAngles[1]) / 2) - - cone_mesh = trimesh.creation.cone(radius=radius, height=padded_height) - - position_matrix = translation_matrix((0, 0, -1 * padded_height)) - cone_mesh.apply_transform(position_matrix) - - # Create two cones around the yaw axis - orientation_1 = Orientation._fromEuler(0, 0, 0) - orientation_2 = Orientation._fromEuler(0, 0, math.pi) - - cone_1 = MeshVolumeRegion( - mesh=cone_mesh, rotation=orientation_1, centerMesh=False - ) - cone_2 = MeshVolumeRegion( - mesh=cone_mesh, rotation=orientation_2, centerMesh=False - ) - - view_region = base_sphere.difference(cone_1).difference(cone_2) - else: - # Case 2.b - view_region = base_sphere.intersect( - ViewSectionRegion(visibleDistance, viewAngles) - ) + view_region = base_sphere.intersect( + ViewSectionRegion(visibleDistance, viewAngles) + ) assert view_region is not None + assert isinstance(view_region, MeshVolumeRegion) + assert view_region.containsPoint(Vector(0, 0, 0)) # Initialize volume region super().__init__( @@ -3717,8 +3667,9 @@ def __init__(self, visibleDistance, viewAngles, rotation=None, resolution=32): triangles.append((bot_line[li], bot_line[li + 1], top_line[li + 1])) # Side triangles - triangles.append((bot_line[0], top_line[0], (0, 0, 0))) - triangles.append((top_line[-1], bot_line[-1], (0, 0, 0))) + if viewAngles[0] < math.tau: + triangles.append((bot_line[0], top_line[0], (0, 0, 0))) + triangles.append((top_line[-1], bot_line[-1], (0, 0, 0))) # Top/Bottom triangles for li in range(len(top_line) - 1): diff --git a/src/scenic/core/utils.py b/src/scenic/core/utils.py index 123cbe4d2..ce5333471 100644 --- a/src/scenic/core/utils.py +++ b/src/scenic/core/utils.py @@ -163,10 +163,15 @@ def loadMesh(path, filetype, compressed, binary): def unifyMesh(mesh, verbose=False): - """Attempt to merge mesh bodies, aborting if something fails. + """Attempt to merge mesh bodies, raising a `ValueError` if something fails. - Should only be used with meshes that are volumes. Returns the - original mesh if something goes wrong. + Should only be used with meshes that are volumes. + + If a mesh is composed of multiple bodies, the following process + is applied: + 1. Split mesh into volumes and holes. + 2. From each volume, subtract each hole that is fully contained. + 3. Union all the resulting volumes. """ assert mesh.is_volume @@ -176,30 +181,45 @@ def unifyMesh(mesh, verbose=False): mesh_bodies = mesh.split() - if not all(m.is_volume for m in mesh_bodies): - if verbose: - warnings.warn( - "The mesh that you loaded was composed of multiple bodies," - " but Scenic was unable to unify it because some of those bodies" - " are non-volumetric (e.g. hollow portions of a volume). This is probably" - " not an issue, but note that if any of these bodies have" - " intersecting faces, Scenic may give undefined resuls. To suppress" - " this warning in the future, consider adding the 'unify=False' parameter" - " to your fromFile call." - ) - return mesh + if all(m.is_volume for m in mesh_bodies): + # If all mesh bodies are volumes, we can just return the union. + unified_mesh = trimesh.boolean.union(mesh_bodies) - try: - unified_mesh = trimesh.boolean.union(mesh_bodies, engine="scad") - except CalledProcessError: - # Something went wrong, return the original mesh + else: + # Split the mesh bodies into volumes and holes. + volumes = [] + holes = [] + for m in mesh_bodies: + if m.is_volume: + volumes.append(m) + else: + m.fix_normals() + assert m.is_volume + holes.append(m) + + # For each volume, subtract all holes fully contained in the volume, + # keeping track of which holes are fully contained in at least one solid. + differenced_volumes = [] + contained_holes = set() + + for v in volumes: + for h in filter(lambda h: h.volume < v.volume, holes): + if h.difference(v).is_empty: + contained_holes.add(h) + v = v.difference(h) + differenced_volumes.append(v) + + # If one or more holes was not fully contained (and thus ignored), + # raise a warning. if verbose: - warnings.warn( - "The mesh that you loaded was composed of multiple bodies," - " but Scenic was unable to unify it because OpenSCAD raised" - " an error." - ) - return mesh + if contained_holes != set(holes): + warnings.warn( + "One or more holes in the provided mesh was not fully contained" + " in any solid (and was ignored)." + ) + + # Union all the differenced volumes together. + unified_mesh = trimesh.boolean.union(differenced_volumes) # Check that the output is still a valid mesh if unified_mesh.is_volume: @@ -207,30 +227,23 @@ def unifyMesh(mesh, verbose=False): if unified_mesh.body_count == 1: warnings.warn( "The mesh that you loaded was composed of multiple bodies," - " but Scenic was able to unify it into one single body. To save on compile" + " but Scenic was able to unify it into one single body (though" + " you should verify that the result is correct). To save on compile" " time in the future, consider running unifyMesh on your mesh outside" " of Scenic and using that output instead." ) elif unified_mesh.body_count < mesh.body_count: warnings.warn( "The mesh that you loaded was composed of multiple bodies," - " but Scenic was able to unify it into fewer bodies. To save on compile" + " but Scenic was able to unify it into fewer bodies (though" + " you should verify that the result is correct). To save on compile" " time in the future, consider running unifyMesh on your mesh outside" - " of Scenic and using that output instead. Note that if any of these" - " bodies have intersecting faces, Scenic may give undefined resuls." + " of Scenic and using that output instead." ) return unified_mesh else: - if verbose: - warnings.warn( - "The mesh that you loaded was composed of multiple bodies," - " and Scenic was unable to unify it into fewer bodies. To save on compile" - " time in the future, consider adding the 'unify=False' parameter to your" - " fromFile call. Note that if any of these bodies have intersecting faces," - " Scenic may give undefined resuls." - ) - return mesh + raise ValueError("Unable to unify mesh.") def repairMesh(mesh, pitch=(1 / 2) ** 6, verbose=True): diff --git a/tests/core/test_regions.py b/tests/core/test_regions.py index 81b998096..c5fa83f1e 100644 --- a/tests/core/test_regions.py +++ b/tests/core/test_regions.py @@ -273,18 +273,13 @@ def test_mesh_surface_region_negative_dimension(): MeshSurfaceRegion(mesh, dimensions=dims) -def test_mesh_operation_blender(): - r1 = BoxRegion(position=(0, 0, 0), dimensions=(1, 1, 1), engine="blender") - r2 = BoxRegion(position=(0, 0, 0), dimensions=(2, 2, 2), engine="blender") +def test_mesh_operation(): + r1 = BoxRegion(position=(0, 0, 0), dimensions=(2, 2, 2)) + r2 = BoxRegion(position=(0, 0, 0), dimensions=(1, 1, 1)) - r = r1.intersect(r2) - - -def test_mesh_operation_scad(): - r1 = BoxRegion(position=(0, 0, 0), dimensions=(1, 1, 1), engine="scad") - r2 = BoxRegion(position=(0, 0, 0), dimensions=(2, 2, 2), engine="scad") - - r = r1.intersect(r2) + r1.intersect(r2) + r1.union(r2) + r1.difference(r2) def test_mesh_volume_region_sampling(): @@ -316,19 +311,17 @@ def test_mesh_intersects(): def test_mesh_empty_intersection(): - for engine in ["blender", "scad"]: - r1 = BoxRegion(position=(0, 0, 0), engine=engine) - r2 = BoxRegion(position=(10, 10, 10), engine=engine) + r1 = BoxRegion(position=(0, 0, 0)) + r2 = BoxRegion(position=(10, 10, 10)) - assert isinstance(r1.intersect(r2), EmptyRegion) + assert isinstance(r1.intersect(r2), EmptyRegion) def test_mesh_empty_difference(): - for engine in ["blender", "scad"]: - r1 = BoxRegion(dimensions=(1, 1, 1), engine=engine) - r2 = BoxRegion(dimensions=(2, 2, 2), engine=engine) + r1 = BoxRegion(dimensions=(1, 1, 1)) + r2 = BoxRegion(dimensions=(2, 2, 2)) - assert isinstance(r1.difference(r2), EmptyRegion) + assert isinstance(r1.difference(r2), EmptyRegion) def test_path_region(): @@ -550,9 +543,9 @@ def test_pointset_region(): # ViewRegion tests -H_ANGLES = [0.1, 45, 90, 135, 179.9, 180, 180.1, 225, 270, 315, 359.9, 360] +H_ANGLES = [0.95, 45, 90, 135, 177.5, 180, 180.01, 225, 270, 315, 358.99, 360] -V_ANGLES = [0.1, 45, 90, 135, 179.9, 180] +V_ANGLES = [0.95, 45, 90, 135, 177.5, 180] VISIBLE_DISTANCES = [1, 25, 50] diff --git a/tests/core/test_utils.py b/tests/core/test_utils.py index f82d44799..de2f8e6a7 100644 --- a/tests/core/test_utils.py +++ b/tests/core/test_utils.py @@ -4,7 +4,7 @@ import pytest import trimesh -from scenic.core.utils import loadMesh, repairMesh +from scenic.core.utils import loadMesh, repairMesh, unifyMesh @pytest.mark.slow @@ -46,3 +46,23 @@ def test_mesh_repair(getAssetPath): with pytest.raises(ValueError): repairMesh(plane) + + +@pytest.mark.slow +def test_unify_mesh(): + # Create nested sphere + nested_sphere = ( + trimesh.creation.icosphere(radius=5) + .difference(trimesh.creation.icosphere(radius=4)) + .union(trimesh.creation.icosphere(radius=3)) + .difference(trimesh.creation.icosphere(radius=2)) + ) + + # Manually append a box + bad_mesh = trimesh.util.concatenate( + nested_sphere, trimesh.creation.box(bounds=((0, 0, 0), (3, 5, 3))) + ) + + fixed_mesh = unifyMesh(bad_mesh) + assert fixed_mesh.is_volume + assert fixed_mesh.body_count == 3