diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 03d9ecf4d..fb3ca2e28 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,9 +11,9 @@ jobs: name: Check Code Formatting runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.12" - name: Install Formatting @@ -38,9 +38,9 @@ jobs: - os: macos-13 python-version: 3.9 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Test a minimal install @@ -57,9 +57,9 @@ jobs: needs: [tests, containers] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.11' - name: Install publishing dependencies @@ -81,7 +81,7 @@ jobs: - name: Log In to Docker Hub run: echo ${{ secrets.DH_PASS }} | docker login --username mikedh --password-stdin - name: Checkout trimesh - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Build Images And Docs env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} @@ -104,15 +104,15 @@ jobs: runs-on: ubuntu-latest name: Check Corpus Loading steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Trimesh DiskCache id: cache-resolvers - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ~/.trimesh-cache key: trimesh-cache - name: Set up Python 3.10 - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.10" - name: Install Trimesh @@ -128,7 +128,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Tag Version id: set_tag run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 18c242a94..0b4c9c331 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,9 +10,9 @@ jobs: name: Check Formatting runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.12" - name: Install @@ -28,9 +28,9 @@ jobs: python-version: ["3.8", "3.12"] os: [ubuntu-latest, windows-latest, macos-13] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Test a minimal install @@ -46,7 +46,7 @@ jobs: name: Run Tests In Docker runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Run Pytest In Docker run: make tests @@ -54,15 +54,15 @@ jobs: runs-on: ubuntu-latest name: Check Corpus Loading steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Trimesh DiskCache id: cache-resolvers - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ~/.trimesh-cache key: trimesh-cache - name: Set up Python 3.11 - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.11" - name: Install Trimesh diff --git a/Dockerfile b/Dockerfile index 0ff1b91fa..70c4b50af 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,12 @@ RUN useradd -m -u 499 -s /bin/bash user && \ USER user WORKDIR /home/user + +# install a python `venv` +# this seems a little silly since we're already in a container +# but if you use Debian methods like `update-alternatives` +# it won't provide a `pip` which works easily and it isn't +# easy to know how system packages interact with pip packages RUN python3.12 -m venv venv # So scripts installed from pip are in $PATH @@ -23,13 +29,25 @@ COPY --chmod=755 docker/trimesh-setup /home/user/venv/bin ## install things that need building FROM base AS build +USER root +# install wget for fetching wheels +RUN apt-get update && \ + apt-get install --no-install-recommends -qq -y wget ca-certificates && \ + apt-get clean -y +USER user + # copy in essential files COPY --chown=499 trimesh/ /home/user/trimesh COPY --chown=499 pyproject.toml /home/user/ -# install trimesh into .local +# install trimesh into the venv RUN pip install /home/user[easy] +# install FCL, which currently has broken wheels on pypi +RUN wget https://github.com/BerkeleyAutomation/python-fcl/releases/download/v0.7.0.7/python_fcl-0.7.0.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl && \ + pip install python_fcl*.whl && \ + rm python_fcl*.whl + #################################### ### Build output image most things should run on FROM base AS output @@ -55,8 +73,7 @@ RUN trimesh-setup --install=test,gmsh,gltf_validator,llvmpipe,binvox USER user # install things like pytest and make sure we're on Numpy 2.X -# todo : imagio is forcing a downgrade of numpy 2 in-place -RUN pip install .[all] && pip install --force-reinstall --upgrade "numpy>2" && \ +RUN pip install .[all] && \ python -c "import numpy as n; assert(n.__version__.startswith('2'))" # check for lint problems diff --git a/pyproject.toml b/pyproject.toml index 8c318df72..7c3b08fa7 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.4.7" +version = "4.4.8" authors = [{name = "Michael Dawson-Haggerty", email = "mikedh@kerfed.com"}] license = {file = "LICENSE.md"} description = "Import, export, process, analyze and view triangular meshes." @@ -90,7 +90,8 @@ recommend = [ "pyglet<2", "psutil", "scikit-image", - "python-fcl", # do collision checks + "fast-simplification", + # "python-fcl", # do collision checks # TODO : broken on numpy 2 "openctm", # load `CTM` compressed models "cascadio", # load `STEP` files @@ -159,7 +160,6 @@ flake8-implicit-str-concat = {"allow-multiline" = false} "IPython.embed".msg = "you forgot to remove a debug embed ;)" "numpy.empty".msg = "uninitialized arrays are haunted try numpy.zeros" - [tool.codespell] skip = "*.js*,./docs/built/*,./docs/generate/*,./models*,*.toml" ignore-words-list = "nd,coo,whats,bu,childs,mis,filetests" @@ -169,4 +169,4 @@ python_version = "3.8" ignore_missing_imports = true disallow_untyped_defs = false disallow_untyped_calls = false -disable_error_code = ["method-assign"] \ No newline at end of file +disable_error_code = ["method-assign", "var-annotated"] \ No newline at end of file diff --git a/tests/test_quadric.py b/tests/test_quadric.py new file mode 100644 index 000000000..7be01f5cd --- /dev/null +++ b/tests/test_quadric.py @@ -0,0 +1,28 @@ +try: + from . import generic as g +except BaseException: + import generic as g + + +def test_quadric_simplification(): + if not g.trimesh.util.has_module("fast_simplification"): + return + + m = g.get_mesh("rabbit.obj") + assert len(m.faces) > 600 + + # should be about half as large + a = m.simplify_quadric_decimation(percent=0.5) + assert g.np.isclose(len(a.faces), len(m.faces) // 2, rtol=0.2) + + # should have the requested number of faces + a = m.simplify_quadric_decimation(face_count=200) + assert len(a.faces) == 200 + + # see if aggression does anything + a = m.simplify_quadric_decimation(percent=0.25, aggression=5) + assert len(a.faces) > 0 + + +if __name__ == "__main__": + test_quadric_simplification() diff --git a/trimesh/base.py b/trimesh/base.py index 082007317..5e5ea49f8 100644 --- a/trimesh/base.py +++ b/trimesh/base.py @@ -13,7 +13,6 @@ from . import ( boolean, - caching, comparison, convex, curvature, @@ -36,14 +35,26 @@ units, util, ) -from .caching import TrackedArray +from .caching import Cache, DataStore, TrackedArray, cache_decorator from .constants import log, tol from .exceptions import ExceptionWrapper from .exchange.export import export_mesh from .parent import Geometry3D from .scene import Scene from .triangles import MassProperties -from .typed import Any, ArrayLike, Dict, List, NDArray, Number, Optional, Sequence, Union +from .typed import ( + Any, + ArrayLike, + Dict, + Floating, + Integer, + List, + NDArray, + Number, + Optional, + Sequence, + Union, +) from .visual import ColorVisuals, TextureVisuals, create_visual try: @@ -130,13 +141,13 @@ def __init__( # any data put into the store is converted to a TrackedArray # which is a subclass of np.ndarray that provides hash and crc # methods which can be used to detect changes in the array. - self._data = caching.DataStore() + self._data = DataStore() # self._cache stores information about the mesh which CAN be # regenerated from self._data, but may be slow to calculate. # In order to maintain consistency # the cache is cleared when self._data.__hash__() changes - self._cache = caching.Cache(id_function=self._data.__hash__, force_immutable=True) + self._cache = Cache(id_function=self._data.__hash__, force_immutable=True) if initial_cache is not None: self._cache.update(initial_cache) @@ -325,7 +336,7 @@ def faces(self, values: Optional[ArrayLike]) -> None: self._data["faces"] = values - @caching.cache_decorator + @cache_decorator def faces_sparse(self) -> coo_matrix: """ A sparse matrix representation of the faces. @@ -464,7 +475,7 @@ def vertices(self, values: Optional[ArrayLike]): values = np.zeros(shape=(0, 3), dtype=float64) self._data["vertices"] = np.asanyarray(values, order="C", dtype=float64) - @caching.cache_decorator + @cache_decorator def vertex_normals(self) -> NDArray[float64]: """ The vertex normals of the mesh. If the normals were loaded @@ -507,7 +518,7 @@ def vertex_normals(self, values: ArrayLike) -> None: log.debug("vertex_normals are all zero!") self._cache["vertex_normals"] = values - @caching.cache_decorator + @cache_decorator def vertex_faces(self) -> NDArray[int64]: """ A representation of the face indices that correspond to each vertex. @@ -526,7 +537,7 @@ def vertex_faces(self) -> NDArray[int64]: ) return vertex_faces - @caching.cache_decorator + @cache_decorator def bounds(self) -> Optional[NDArray[float64]]: """ The axis aligned bounds of the faces of the mesh. @@ -545,7 +556,7 @@ def bounds(self) -> Optional[NDArray[float64]]: # get mesh bounds with min and max return np.array([in_mesh.min(axis=0), in_mesh.max(axis=0)]) - @caching.cache_decorator + @cache_decorator def extents(self) -> Optional[NDArray[float64]]: """ The length, width, and height of the axis aligned @@ -564,7 +575,7 @@ def extents(self) -> Optional[NDArray[float64]]: return extents - @caching.cache_decorator + @cache_decorator def centroid(self) -> NDArray[float64]: """ The point in space which is the average of the triangle @@ -725,7 +736,7 @@ def moment_inertia_frame(self, transform: ArrayLike) -> NDArray[float64]: parallel_axis=True, ) - @caching.cache_decorator + @cache_decorator def principal_inertia_components(self) -> NDArray[float64]: """ Return the principal components of inertia @@ -759,7 +770,7 @@ def principal_inertia_vectors(self) -> NDArray[float64]: _ = self.principal_inertia_components return self._cache["principal_inertia_vectors"] - @caching.cache_decorator + @cache_decorator def principal_inertia_transform(self) -> NDArray[float64]: """ A transform which moves the current mesh so the principal @@ -784,7 +795,7 @@ def principal_inertia_transform(self) -> NDArray[float64]: return transform - @caching.cache_decorator + @cache_decorator def symmetry(self) -> Optional[str]: """ Check whether a mesh has rotational symmetry around @@ -829,7 +840,7 @@ def symmetry_section(self) -> Optional[NDArray[float64]]: return None return self._cache["symmetry_section"] - @caching.cache_decorator + @cache_decorator def triangles(self) -> NDArray[float64]: """ Actual triangles of the mesh (points, not indexes) @@ -844,7 +855,7 @@ def triangles(self) -> NDArray[float64]: # recomputed. We can escape this check by viewing the array. return self.vertices.view(np.ndarray)[self.faces] - @caching.cache_decorator + @cache_decorator def triangles_tree(self) -> Index: """ An R-tree containing each face of the mesh. @@ -856,7 +867,7 @@ def triangles_tree(self) -> Index: """ return triangles.bounds_tree(self.triangles) - @caching.cache_decorator + @cache_decorator def triangles_center(self) -> NDArray[float64]: """ The center of each triangle (barycentric [1/3, 1/3, 1/3]) @@ -868,7 +879,7 @@ def triangles_center(self) -> NDArray[float64]: """ return self.triangles.mean(axis=1) - @caching.cache_decorator + @cache_decorator def triangles_cross(self) -> NDArray[float64]: """ The cross product of two edges of each triangle. @@ -881,7 +892,7 @@ def triangles_cross(self) -> NDArray[float64]: crosses = triangles.cross(self.triangles) return crosses - @caching.cache_decorator + @cache_decorator def edges(self) -> NDArray[int64]: """ Edges of the mesh (derived from faces). @@ -897,7 +908,7 @@ def edges(self) -> NDArray[int64]: self._cache["edges_face"] = index return edges - @caching.cache_decorator + @cache_decorator def edges_face(self) -> NDArray[int64]: """ Which face does each edge belong to. @@ -910,7 +921,7 @@ def edges_face(self) -> NDArray[int64]: _ = self.edges return self._cache["edges_face"] - @caching.cache_decorator + @cache_decorator def edges_unique(self) -> NDArray[int64]: """ The unique edges of the mesh. @@ -928,7 +939,7 @@ def edges_unique(self) -> NDArray[int64]: self._cache["edges_unique_inverse"] = inverse return edges_unique - @caching.cache_decorator + @cache_decorator def edges_unique_length(self) -> NDArray[float64]: """ How long is each unique edge. @@ -942,7 +953,7 @@ def edges_unique_length(self) -> NDArray[float64]: length = util.row_norm(vector) return length - @caching.cache_decorator + @cache_decorator def edges_unique_inverse(self) -> NDArray[int64]: """ Return the inverse required to reproduce @@ -959,7 +970,7 @@ def edges_unique_inverse(self) -> NDArray[int64]: _ = self.edges_unique return self._cache["edges_unique_inverse"] - @caching.cache_decorator + @cache_decorator def edges_sorted(self) -> NDArray[int64]: """ Edges sorted along axis 1 @@ -972,7 +983,7 @@ def edges_sorted(self) -> NDArray[int64]: edges_sorted = np.sort(self.edges, axis=1) return edges_sorted - @caching.cache_decorator + @cache_decorator def edges_sorted_tree(self) -> cKDTree: """ A KDTree for mapping edges back to edge index. @@ -985,7 +996,7 @@ def edges_sorted_tree(self) -> cKDTree: """ return cKDTree(self.edges_sorted) - @caching.cache_decorator + @cache_decorator def edges_sparse(self) -> coo_matrix: """ Edges in sparse bool COO graph format where connected @@ -999,7 +1010,7 @@ def edges_sparse(self) -> coo_matrix: sparse = graph.edges_to_coo(self.edges, count=len(self.vertices)) return sparse - @caching.cache_decorator + @cache_decorator def body_count(self) -> int: """ How many connected groups of vertices exist in this mesh. @@ -1018,7 +1029,7 @@ def body_count(self) -> int: self._cache["vertices_component_label"] = labels return count - @caching.cache_decorator + @cache_decorator def faces_unique_edges(self) -> NDArray[int64]: """ For each face return which indexes in mesh.unique_edges constructs @@ -1052,7 +1063,7 @@ def faces_unique_edges(self) -> NDArray[int64]: result = self._cache["edges_unique_inverse"].reshape((-1, 3)) return result - @caching.cache_decorator + @cache_decorator def euler_number(self) -> int: """ Return the Euler characteristic (a topological invariant) for the mesh @@ -1068,7 +1079,7 @@ def euler_number(self) -> int: self.referenced_vertices.sum() - len(self.edges_unique) + len(self.faces) ) - @caching.cache_decorator + @cache_decorator def referenced_vertices(self) -> NDArray[np.bool_]: """ Which vertices in the current mesh are referenced by a face. @@ -1326,7 +1337,7 @@ def split(self, **kwargs) -> List["Trimesh"]: """ return graph.split(self, **kwargs) - @caching.cache_decorator + @cache_decorator def face_adjacency(self) -> NDArray[int64]: """ Find faces that share an edge i.e. 'adjacent' faces. @@ -1366,7 +1377,7 @@ def face_adjacency(self) -> NDArray[int64]: self._cache["face_adjacency_edges"] = edges return adjacency - @caching.cache_decorator + @cache_decorator def face_neighborhood(self) -> NDArray[int64]: """ Find faces that share a vertex i.e. 'neighbors' faces. @@ -1378,7 +1389,7 @@ def face_neighborhood(self) -> NDArray[int64]: """ return graph.face_neighborhood(self) - @caching.cache_decorator + @cache_decorator def face_adjacency_edges(self) -> NDArray[int64]: """ Returns the edges that are shared by the adjacent faces. @@ -1392,7 +1403,7 @@ def face_adjacency_edges(self) -> NDArray[int64]: _ = self.face_adjacency return self._cache["face_adjacency_edges"] - @caching.cache_decorator + @cache_decorator def face_adjacency_edges_tree(self) -> cKDTree: """ A KDTree for mapping edges back face adjacency index. @@ -1405,7 +1416,7 @@ def face_adjacency_edges_tree(self) -> cKDTree: """ return cKDTree(self.face_adjacency_edges) - @caching.cache_decorator + @cache_decorator def face_adjacency_angles(self) -> NDArray[float64]: """ Return the angle between adjacent faces @@ -1422,7 +1433,7 @@ def face_adjacency_angles(self) -> NDArray[float64]: angles = geometry.vector_angle(pairs) return angles - @caching.cache_decorator + @cache_decorator def face_adjacency_projections(self) -> NDArray[float64]: """ The projection of the non-shared vertex of a triangle onto @@ -1437,7 +1448,7 @@ def face_adjacency_projections(self) -> NDArray[float64]: projections = convex.adjacency_projections(self) return projections - @caching.cache_decorator + @cache_decorator def face_adjacency_convex(self) -> NDArray[np.bool_]: """ Return faces which are adjacent and locally convex. @@ -1453,7 +1464,7 @@ def face_adjacency_convex(self) -> NDArray[np.bool_]: """ return self.face_adjacency_projections < tol.merge - @caching.cache_decorator + @cache_decorator def face_adjacency_unshared(self) -> NDArray[int64]: """ Return the vertex index of the two vertices not in the shared @@ -1466,7 +1477,7 @@ def face_adjacency_unshared(self) -> NDArray[int64]: """ return graph.face_adjacency_unshared(self) - @caching.cache_decorator + @cache_decorator def face_adjacency_radius(self) -> NDArray[float64]: """ The approximate radius of a cylinder that fits inside adjacent faces. @@ -1479,7 +1490,7 @@ def face_adjacency_radius(self) -> NDArray[float64]: radii, self._cache["face_adjacency_span"] = graph.face_adjacency_radius(mesh=self) return radii - @caching.cache_decorator + @cache_decorator def face_adjacency_span(self) -> NDArray[float64]: """ The approximate perpendicular projection of the non-shared @@ -1494,7 +1505,7 @@ def face_adjacency_span(self) -> NDArray[float64]: _ = self.face_adjacency_radius return self._cache["face_adjacency_span"] - @caching.cache_decorator + @cache_decorator def integral_mean_curvature(self) -> float64: """ The integral mean curvature, or the surface integral of the mean curvature. @@ -1509,7 +1520,7 @@ def integral_mean_curvature(self) -> float64: ) return (self.face_adjacency_angles * edges_length).sum() * 0.5 - @caching.cache_decorator + @cache_decorator def vertex_adjacency_graph(self) -> Graph: """ Returns a networkx graph representing the vertices and their connections @@ -1534,7 +1545,7 @@ def vertex_adjacency_graph(self) -> Graph: return graph.vertex_adjacency_graph(mesh=self) - @caching.cache_decorator + @cache_decorator def vertex_neighbors(self) -> List[List[int64]]: """ The vertex neighbors of each vertex of the mesh, determined from @@ -1557,7 +1568,7 @@ def vertex_neighbors(self) -> List[List[int64]]: """ return graph.neighbors(edges=self.edges_unique, max_index=len(self.vertices)) - @caching.cache_decorator + @cache_decorator def is_winding_consistent(self) -> bool: """ Does the mesh have consistent winding or not. @@ -1575,7 +1586,7 @@ def is_winding_consistent(self) -> bool: _ = self.is_watertight return self._cache["is_winding_consistent"] - @caching.cache_decorator + @cache_decorator def is_watertight(self) -> bool: """ Check if a mesh is watertight by making sure every edge is @@ -1594,7 +1605,7 @@ def is_watertight(self) -> bool: self._cache["is_winding_consistent"] = winding return watertight - @caching.cache_decorator + @cache_decorator def is_volume(self) -> bool: """ Check if a mesh has all the properties required to represent @@ -1628,7 +1639,7 @@ def is_empty(self) -> bool: """ return self._data.is_empty() - @caching.cache_decorator + @cache_decorator def is_convex(self) -> bool: """ Check if a mesh is convex or not. @@ -1644,7 +1655,7 @@ def is_convex(self) -> bool: is_convex = bool(convex.is_convex(self)) return is_convex - @caching.cache_decorator + @cache_decorator def kdtree(self) -> cKDTree: """ Return a scipy.spatial.cKDTree of the vertices of the mesh. @@ -1699,7 +1710,7 @@ def nondegenerate_faces(self, height: float = tol.merge) -> NDArray[np.bool_]: self.triangles, areas=self.area_faces, height=height ) - @caching.cache_decorator + @cache_decorator def facets(self) -> List[NDArray[int64]]: """ Return a list of face indices for coplanar adjacent faces. @@ -1712,7 +1723,7 @@ def facets(self) -> List[NDArray[int64]]: facets = graph.facets(self) return facets - @caching.cache_decorator + @cache_decorator def facets_area(self) -> NDArray[float64]: """ Return an array containing the area of each facet. @@ -1731,7 +1742,7 @@ def facets_area(self) -> NDArray[float64]: areas = np.array([sum(area_faces[i]) for i in self.facets], dtype=float64) return areas - @caching.cache_decorator + @cache_decorator def facets_normal(self) -> NDArray[float64]: """ Return the normal of each facet @@ -1757,7 +1768,7 @@ def facets_normal(self) -> NDArray[float64]: return normals - @caching.cache_decorator + @cache_decorator def facets_origin(self) -> NDArray[float64]: """ Return a point on the facet plane. @@ -1770,7 +1781,7 @@ def facets_origin(self) -> NDArray[float64]: _ = self.facets_normal return self._cache["facets_origin"] - @caching.cache_decorator + @cache_decorator def facets_boundary(self) -> List[NDArray[int64]]: """ Return the edges which represent the boundary of each facet @@ -1787,7 +1798,7 @@ def facets_boundary(self) -> List[NDArray[int64]]: edges_boundary = [i[grouping.group_rows(i, require_count=1)] for i in edges_facet] return edges_boundary - @caching.cache_decorator + @cache_decorator def facets_on_hull(self) -> NDArray[np.bool_]: """ Find which facets of the mesh are on the convex hull. @@ -2336,7 +2347,7 @@ def unwrap(self, image=None): return result - @caching.cache_decorator + @cache_decorator def convex_hull(self) -> "Trimesh": """ Returns a Trimesh object representing the convex hull of @@ -2527,56 +2538,50 @@ def voxelized(self, pitch, method="subdivide", **kwargs): return creation.voxelize(mesh=self, pitch=pitch, method=method, **kwargs) - @caching.cache_decorator - def as_open3d(self): - """ - Return an `open3d.geometry.TriangleMesh` version of - the current mesh. - - Returns - --------- - open3d : open3d.geometry.TriangleMesh - Current mesh as an open3d object. - """ - import open3d - - # create from numpy arrays - return open3d.geometry.TriangleMesh( - vertices=open3d.utility.Vector3dVector(self.vertices.copy()), - triangles=open3d.utility.Vector3iVector(self.faces.copy()), - ) - - def simplify_quadratic_decimation(self, *args, **kwargs): - """ - DERECATED MARCH 2024 REPLACE WITH: - `mesh.simplify_quadric_decimation` - """ - warnings.warn( - "`simplify_quadratic_decimation` is deprecated " - + "as it was a typo and will be removed in March 2024: " - + "replace with `simplify_quadric_decimation`", - category=DeprecationWarning, - stacklevel=2, - ) - return self.simplify_quadric_decimation(*args, **kwargs) - - def simplify_quadric_decimation(self, face_count: int) -> "Trimesh": + def simplify_quadric_decimation( + self, + percent: Optional[Floating] = None, + face_count: Optional[Integer] = None, + aggression: Optional[Integer] = None, + ) -> "Trimesh": """ - A thin wrapper around the `open3d` implementation of this: - `open3d.geometry.TriangleMesh.simplify_quadric_decimation` + A thin wrapper around `pip install fast-simplification`. Parameters ----------- - face_count : int - Number of faces desired in the resulting mesh. + percent + A number between 0.0 and 1.0 for how much + face_count + Target number of faces desired in the resulting mesh. + agression + An integer between `0` and `10`, the scale being roughly + `0` is "slow and good" and `10` being "fast and bad." Returns --------- simple : trimesh.Trimesh Simplified version of mesh. """ - simple = self.as_open3d.simplify_quadric_decimation(int(face_count)) - return Trimesh(vertices=simple.vertices, faces=simple.triangles) + from fast_simplification import simplify + + # create keyword arguments as dict so we can filter out `None` + # values as the C wrapper as of writing is not happy with `None` + # and requires they be omitted from the constructor + kwargs = { + "target_count": face_count, + "target_reduction": percent, + "agg": aggression, + } + + # todo : one could take the `return_collapses=True` array and use it to + # apply the same simplification to the visual info + vertices, faces = simplify( + points=self.vertices.view(np.ndarray), + triangles=self.faces.view(np.ndarray), + **{k: v for k, v in kwargs.items() if v is not None}, + ) + + return Trimesh(vertices=vertices, faces=faces) def outline(self, face_ids: Optional[NDArray[int64]] = None, **kwargs) -> Path3D: """ @@ -2644,7 +2649,7 @@ def projected(self, normal, **kwargs) -> Path2D: return Path2D() return load_path(projection) - @caching.cache_decorator + @cache_decorator def area(self) -> float64: """ Summed area of all triangles in the current mesh. @@ -2657,7 +2662,7 @@ def area(self) -> float64: area = self.area_faces.sum() return area - @caching.cache_decorator + @cache_decorator def area_faces(self) -> NDArray[float64]: """ The area of each face in the mesh. @@ -2669,7 +2674,7 @@ def area_faces(self) -> NDArray[float64]: """ return triangles.area(crosses=self.triangles_cross) - @caching.cache_decorator + @cache_decorator def mass_properties(self) -> MassProperties: """ Returns the mass properties of the current mesh. @@ -2769,7 +2774,7 @@ def submesh( """ return util.submesh(mesh=self, faces_sequence=faces_sequence, **kwargs) - @caching.cache_decorator + @cache_decorator def identifier(self) -> NDArray[float64]: """ Return a float vector which is unique to the mesh @@ -2782,7 +2787,7 @@ def identifier(self) -> NDArray[float64]: """ return comparison.identifier_simple(self) - @caching.cache_decorator + @cache_decorator def identifier_hash(self) -> str: """ A hash of the rotation invariant identifier vector. @@ -2884,7 +2889,7 @@ def union( """ return boolean.union( - meshes=np.append(self, other), + meshes=[self, other], engine=engine, check_volume=check_volume, **kwargs, @@ -2953,7 +2958,7 @@ def intersection( Mesh of the volume contained by all passed meshes """ return boolean.intersection( - meshes=np.append(self, other), + meshes=[self, other], engine=engine, check_volume=check_volume, **kwargs, @@ -2977,7 +2982,7 @@ def contains(self, points: ArrayLike) -> NDArray[np.bool_]: """ return self.ray.contains_points(points) - @caching.cache_decorator + @cache_decorator def face_angles(self) -> NDArray[float64]: """ Returns the angle at each vertex of a face. @@ -2989,7 +2994,7 @@ def face_angles(self) -> NDArray[float64]: """ return triangles.angles(self.triangles) - @caching.cache_decorator + @cache_decorator def face_angles_sparse(self) -> coo_matrix: """ A sparse matrix representation of the face angles. @@ -3003,7 +3008,7 @@ def face_angles_sparse(self) -> coo_matrix: angles = curvature.face_angles_sparse(self) return angles - @caching.cache_decorator + @cache_decorator def vertex_defects(self) -> NDArray[float64]: """ Return the vertex defects, or (2*pi) minus the sum of the angles @@ -3021,7 +3026,7 @@ def vertex_defects(self) -> NDArray[float64]: defects = curvature.vertex_defects(self) return defects - @caching.cache_decorator + @cache_decorator def vertex_degree(self) -> NDArray[int64]: """ Return the number of faces each vertex is included in. @@ -3035,7 +3040,7 @@ def vertex_degree(self) -> NDArray[int64]: degree = np.array(self.faces_sparse.sum(axis=1)).flatten() return degree - @caching.cache_decorator + @cache_decorator def face_adjacency_tree(self) -> Index: """ An R-tree of face adjacencies. @@ -3056,7 +3061,7 @@ def face_adjacency_tree(self) -> Index: ) ) - def copy(self, include_cache: bool = False) -> "Trimesh": + def copy(self, include_cache: bool = False, include_visual: bool = True) -> "Trimesh": """ Safely return a copy of the current mesh. @@ -3080,8 +3085,11 @@ def copy(self, include_cache: bool = False) -> "Trimesh": copied = Trimesh() # always deepcopy vertex and face data copied._data.data = copy.deepcopy(self._data.data) - # copy visual information - copied.visual = self.visual.copy() + + if include_visual: + # copy visual information + copied.visual = self.visual.copy() + # get metadata copied.metadata = copy.deepcopy(self.metadata) diff --git a/trimesh/boolean.py b/trimesh/boolean.py index 97ef3408e..71c5108c6 100644 --- a/trimesh/boolean.py +++ b/trimesh/boolean.py @@ -8,7 +8,7 @@ import numpy as np from . import exceptions, interfaces -from .typed import ArrayLike, Callable, Optional +from .typed import Callable, NDArray, Optional, Sequence, Union try: from manifold3d import Manifold, Mesh @@ -18,7 +18,7 @@ def difference( - meshes: ArrayLike, engine: Optional[str] = None, check_volume: bool = True, **kwargs + meshes: Sequence, engine: Optional[str] = None, check_volume: bool = True, **kwargs ): """ Compute the boolean difference between a mesh an n other meshes. @@ -48,7 +48,7 @@ def difference( def union( - meshes: ArrayLike, engine: Optional[str] = None, check_volume: bool = True, **kwargs + meshes: Sequence, engine: Optional[str] = None, check_volume: bool = True, **kwargs ): """ Compute the boolean union between a mesh an n other meshes. @@ -78,7 +78,7 @@ def union( def intersection( - meshes: ArrayLike, engine: Optional[str] = None, check_volume: bool = True, **kwargs + meshes: Sequence, engine: Optional[str] = None, check_volume: bool = True, **kwargs ): """ Compute the boolean intersection between a mesh and other meshes. @@ -107,7 +107,7 @@ def intersection( def boolean_manifold( - meshes: ArrayLike, + meshes: Sequence, operation: str, check_volume: bool = True, **kwargs, @@ -162,7 +162,7 @@ def boolean_manifold( return Trimesh(vertices=result_mesh.vert_properties, faces=result_mesh.tri_verts) -def reduce_cascade(operation: Callable, items: ArrayLike): +def reduce_cascade(operation: Callable, items: Union[Sequence, NDArray]): """ Call an operation function in a cascaded pairwise way against a flat list of items. diff --git a/trimesh/exchange/gltf.py b/trimesh/exchange/gltf.py index 6380bd9ae..856b8b355 100644 --- a/trimesh/exchange/gltf.py +++ b/trimesh/exchange/gltf.py @@ -1436,20 +1436,25 @@ def _read_buffers( if "byteStride" in buffer_view: # how many bytes for each chunk stride = buffer_view["byteStride"] - # the total block we're looking at - length = count * stride # we want to get the bytes for every row per_row = per_count * dtype.itemsize - # we have to offset the (already offset) buffer - # and then pull chunks per-stride - # do as a list comprehension as the numpy - # buffer wangling was - raw = b"".join( - data[i : i + per_row] - for i in range(start, start + length, stride) + # the total block we're looking at + length = (count - 1) * stride + per_row + # apply as_strided for fast construction of strided array + # and copy to ensure contiguous layout + assert stride > 0, "byteStride should be positive" + assert 0 <= start <= start + length <= len(data) + access[index] = np.array( + np.lib.stride_tricks.as_strided( + np.frombuffer( + data, dtype=np.uint8, offset=start, count=length + ), + [count, per_row], + [stride, 1], + ) + .view(dtype) + .reshape(shape) ) - # the reshape should fail if we screwed up - access[index] = np.frombuffer(raw, dtype=dtype).reshape(shape) else: # length is the number of bytes per item times total length = dtype.itemsize * count * per_count diff --git a/trimesh/exchange/obj.py b/trimesh/exchange/obj.py index a5bc65351..841d183ce 100644 --- a/trimesh/exchange/obj.py +++ b/trimesh/exchange/obj.py @@ -348,10 +348,11 @@ def parse_mtl(mtl, resolver=None): # if there is only one value return that if len(value) == 1: value = value[0] - # store the key by mapped value - material[mapped[key]] = value - # also store key by OBJ name - material[key] = value + if material is not None: + # store the key by mapped value + material[mapped[key]] = value + # also store key by OBJ name + material[key] = value except BaseException: log.debug("failed to convert color!", exc_info=True) # pass everything as kwargs to material constructor diff --git a/trimesh/inertia.py b/trimesh/inertia.py index 94b836f88..a91cdd2cb 100644 --- a/trimesh/inertia.py +++ b/trimesh/inertia.py @@ -10,10 +10,13 @@ import numpy as np -from trimesh import util +from .typed import ArrayLike, NDArray, Number, Optional, float64 +from .util import multi_dot -def cylinder_inertia(mass, radius, height, transform=None): +def cylinder_inertia( + mass: Number, radius: Number, height: Number, transform: Optional[ArrayLike] = None +) -> NDArray[float64]: """ Return the inertia tensor of a cylinder. @@ -49,7 +52,7 @@ def cylinder_inertia(mass, radius, height, transform=None): return inertia -def sphere_inertia(mass, radius): +def sphere_inertia(mass: Number, radius: Number) -> NDArray[float64]: """ Return the inertia tensor of a sphere. @@ -65,11 +68,10 @@ def sphere_inertia(mass, radius): inertia : (3, 3) float Inertia tensor """ - inertia = (2.0 / 5.0) * (radius**2) * mass * np.eye(3) - return inertia + return (2.0 / 5.0) * (radius**2) * mass * np.eye(3) -def principal_axis(inertia): +def principal_axis(inertia: ArrayLike): """ Find the principal components and principal axis of inertia from the inertia tensor. @@ -103,7 +105,12 @@ def principal_axis(inertia): return components, vectors -def transform_inertia(transform, inertia_tensor, parallel_axis=False, mass=None): +def transform_inertia( + transform: ArrayLike, + inertia_tensor: ArrayLike, + parallel_axis: bool = False, + mass: Optional[Number] = None, +): """ Transform an inertia tensor to a new frame. @@ -172,9 +179,9 @@ def transform_inertia(transform, inertia_tensor, parallel_axis=False, mass=None) ) aligned_inertia = inertia_tensor + mass * M - return util.multi_dot([rotation.T, aligned_inertia, rotation]) + return multi_dot([rotation.T, aligned_inertia, rotation]) - return util.multi_dot([rotation, inertia_tensor, rotation.T]) + return multi_dot([rotation, inertia_tensor, rotation.T]) def radial_symmetry(mesh): @@ -250,7 +257,7 @@ def radial_symmetry(mesh): return None, None, None -def scene_inertia(scene, transform): +def scene_inertia(scene, transform: Optional[ArrayLike] = None) -> NDArray[float64]: """ Calculate the inertia of a scene about a specific frame. @@ -260,6 +267,11 @@ def scene_inertia(scene, transform): Scene with geometry. transform : None or (4, 4) float Homogeneous transform to compute inertia at. + + Returns + ---------- + moment : (3, 3) + Inertia tensor about requested frame """ # shortcuts for tight loop graph = scene.graph diff --git a/trimesh/interval.py b/trimesh/interval.py index 812780289..9fa8c0d00 100644 --- a/trimesh/interval.py +++ b/trimesh/interval.py @@ -95,11 +95,10 @@ def union(intervals: ArrayLike, sort: bool = True) -> NDArray[float64]: intervals = intervals[intervals[:, 0].argsort()] # we know we will have at least one interval - unions = [intervals[0]] + unions = [intervals[0].tolist()] for begin, end in intervals[1:]: if unions[-1][1] >= begin: - # unions[-1][1] = max(unions[-1][1], end) else: unions.append([begin, end]) diff --git a/trimesh/parent.py b/trimesh/parent.py index fa60b8d04..6fde5ea3e 100644 --- a/trimesh/parent.py +++ b/trimesh/parent.py @@ -13,7 +13,7 @@ from . import transformations as tf from .caching import cache_decorator from .constants import tol -from .typed import Dict, Optional +from .typed import Any, ArrayLike, Dict, Optional from .util import ABC @@ -40,7 +40,7 @@ def extents(self): pass @abc.abstractmethod - def apply_transform(self, matrix): + def apply_transform(self, matrix: ArrayLike) -> Any: pass @property @@ -248,10 +248,9 @@ def bounding_box_oriented(self): from . import bounds, primitives to_origin, extents = bounds.oriented_bounds(self) - obb = primitives.Box( + return primitives.Box( transform=np.linalg.inv(to_origin), extents=extents, mutable=False ) - return obb @caching.cache_decorator def bounding_sphere(self): diff --git a/trimesh/repair.py b/trimesh/repair.py index 1672f4814..90a265065 100644 --- a/trimesh/repair.py +++ b/trimesh/repair.py @@ -359,8 +359,8 @@ def stitch(mesh, faces=None, insert_vertices=False): Parameters ----------- - vertices : (n, 3) float - Vertices in space. + mesh : trimesh.Trimesh + Mesh to create fan stitch on. faces : (n,) int Face indexes to stitch with triangle fans. insert_vertices : bool diff --git a/trimesh/typed.py b/trimesh/typed.py index 54cf65c87..67289b2d9 100644 --- a/trimesh/typed.py +++ b/trimesh/typed.py @@ -37,6 +37,11 @@ # these wrappers union numpy integers and python integers Integer = Union[int, integer, unsignedinteger] +# Numbers which can only be floats and will not accept integers +# > isinstance(np.ones(1, dtype=np.float32)[0], floating) # True +# > isinstance(np.ones(1, dtype=np.float32)[0], float) # False +Floating = Union[float, floating] + # Many arguments take "any valid number." Number = Union[float, floating, Integer]