diff --git a/Dockerfile b/Dockerfile index cc93105a3..f994ef1a1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -69,23 +69,18 @@ USER user # install things like pytest RUN pip install -e .[all] -# check formatting -RUN ruff trimesh - - - -## TODO : get typeguard to pass on more/all of the codebase -## this is running on a very arbitrary subset right now! -RUN pytest \ - --typeguard-packages=trimesh.scene,trimesh.base \ - -p no:ALL_DEPENDENCIES \ - -p no:INCLUDE_RENDERING \ - -p no:cacheprovider tests/test_s* +# check for lint problems +RUN ruff check trimesh +# run a limited array of static type checks +# TODO : get this to pass on base +RUN pyright trimesh/base.py || true # run pytest wrapped with xvfb for simple viewer tests -RUN xvfb-run pytest \ +# print more columns so the short summary is usable +RUN COLUMNS=240 xvfb-run pytest \ --cov=trimesh \ + --beartype-packages=trimesh \ -p no:ALL_DEPENDENCIES \ -p no:INCLUDE_RENDERING \ -p no:cacheprovider tests diff --git a/docs/requirements.txt b/docs/requirements.txt index 9274833d5..53e871e52 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,13 +1,13 @@ -pypandoc==1.12 +pypandoc==1.13 recommonmark==0.7.1 jupyter==1.0.0 # get sphinx version range from furo install -furo==2023.9.10 +furo==2024.1.29 myst-parser==2.0.0 -pyopenssl==23.3.0 +pyopenssl==24.1.0 autodocsumm==0.2.12 jinja2==3.1.3 -matplotlib==3.8.2 -nbconvert==7.14.1 +matplotlib==3.8.3 +nbconvert==7.16.2 diff --git a/pyproject.toml b/pyproject.toml index bc7eb0af6..ed64fc06e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ requires = ["setuptools >= 61.0", "wheel"] [project] name = "trimesh" requires-python = ">=3.7" -version = "4.2.0" +version = "4.2.1" authors = [{name = "Michael Dawson-Haggerty", email = "mikedh@kerfed.com"}] license = {file = "LICENSE.md"} description = "Import, export, process, analyze and view triangular meshes." @@ -26,7 +26,8 @@ classifiers = [ "Topic :: Multimedia :: Graphics :: 3D Modeling" ] urls = {Homepage = "https://github.com/mikedh/trimesh"} -dependencies = ["numpy"] + +dependencies = ["numpy>=1.20"] [project.readme] file = "README.md" @@ -91,7 +92,7 @@ recommend = [ "python-fcl", # do collision checks "openctm", # load `CTM` compressed models "cascadio", # load `STEP` files - "manifold3d>=2.3.0" + "manifold3d>=2.3.0" # do boolean operations ] # this is the list of everything that is ever added anywhere @@ -99,14 +100,14 @@ recommend = [ test = [ "pytest-cov", "coveralls", - "mypy", + "pyright", "ezdxf", "pytest", "pymeshlab", "pyinstrument", "matplotlib", "ruff", - "typeguard>=4.1.2" + "pytest-beartype; python_version>='3.10'" ] # requires pip >= 21.2 @@ -150,3 +151,10 @@ flake8-implicit-str-concat = {"allow-multiline" = false} [tool.codespell] skip = "*.js*,./docs/built/*,./docs/generate/*,./models*,*.toml" ignore-words-list = "nd,coo,whats,bu,childs,mis,filetests" + +[tool.mypy] +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 diff --git a/tests/test_convex.py b/tests/test_convex.py index 3433ec0a7..173a6e622 100644 --- a/tests/test_convex.py +++ b/tests/test_convex.py @@ -20,7 +20,7 @@ def test_convex(self): ( False, ( - g.trimesh.creation.box(extents=(1, 1, 1)) + g.trimesh.creation.box(extents=[1, 1, 1]) + g.trimesh.creation.box(bounds=[[10, 10, 10], [12, 12, 12]]) ), ), diff --git a/tests/test_creation.py b/tests/test_creation.py index 581996c16..bc06f9cb8 100644 --- a/tests/test_creation.py +++ b/tests/test_creation.py @@ -100,7 +100,7 @@ def test_spheres(self): assert g.np.allclose(radii, 1.0) # test additional arguments - red_sphere = g.trimesh.creation.icosphere(face_colors=(1.0, 0, 0)) + red_sphere = g.trimesh.creation.icosphere(face_colors=[1.0, 0, 0]) expected = g.np.full((len(red_sphere.faces), 4), (255, 0, 0, 255)) g.np.testing.assert_allclose(red_sphere.visual.face_colors, expected) diff --git a/tests/test_scene.py b/tests/test_scene.py index a86a64482..03473e6d7 100644 --- a/tests/test_scene.py +++ b/tests/test_scene.py @@ -179,6 +179,7 @@ def test_mixed_units(self): # mixed units should be None s = g.trimesh.Scene([a, b]) + assert len(s.geometry) == 2 assert s.units is None # now all units should be meters and scene should report that diff --git a/tests/test_unwrap.py b/tests/test_unwrap.py index 5bc1b16ed..128880a78 100644 --- a/tests/test_unwrap.py +++ b/tests/test_unwrap.py @@ -27,6 +27,13 @@ def test_image(self): # make sure image was attached correctly assert u.visual.material.image.size == image.size + def test_blender_unwrap(self): + if not g.trimesh.interfaces.blender.exists: + return + m = g.get_mesh("rabbit.obj") + # TODO : verify this returns geometry! + _todo = g.trimesh.interfaces.blender.unwrap(m) + if __name__ == "__main__": g.trimesh.util.attach_to_log() diff --git a/trimesh/base.py b/trimesh/base.py index d6a7c716b..ce6637325 100644 --- a/trimesh/base.py +++ b/trimesh/base.py @@ -36,13 +36,14 @@ units, util, ) +from .caching import TrackedArray 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, Optional, Tuple, Union +from .typed import Any, ArrayLike, Dict, List, NDArray, Number, Optional, Sequence, Union from .visual import ColorVisuals, TextureVisuals, create_visual try: @@ -57,7 +58,7 @@ Graph = ExceptionWrapper(E) try: - from rtree import Index + from rtree.index import Index except BaseException as E: Index = ExceptionWrapper(E) @@ -73,12 +74,12 @@ def __init__( self, vertices: Optional[ArrayLike] = None, faces: Optional[ArrayLike] = None, - face_normals: Optional[NDArray[float64]] = None, - vertex_normals: Optional[NDArray[float64]] = None, - face_colors: Optional[NDArray[float64]] = None, - vertex_colors: Optional[NDArray[float64]] = None, - face_attributes: Optional[Dict[str, NDArray]] = None, - vertex_attributes: Optional[Dict[str, NDArray]] = None, + face_normals: Optional[ArrayLike] = None, + vertex_normals: Optional[ArrayLike] = None, + face_colors: Optional[ArrayLike] = None, + vertex_colors: Optional[ArrayLike] = None, + face_attributes: Optional[Dict[str, ArrayLike]] = None, + vertex_attributes: Optional[Dict[str, ArrayLike]] = None, metadata: Optional[Dict[str, Any]] = None, process: bool = True, validate: bool = False, @@ -122,9 +123,6 @@ def __init__( Assigned to self.visual """ - if initial_cache is None: - initial_cache = {} - # self._data stores information about the mesh which # CANNOT be regenerated. # in the base class all that is stored here is vertex and @@ -139,15 +137,16 @@ def __init__( # 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.update(initial_cache) + if initial_cache is not None: + self._cache.update(initial_cache) # check for None only to avoid warning messages in subclasses - if vertices is not None: - # (n, 3) float, set of vertices - self.vertices = vertices - if faces is not None: - # (m, 3) int of triangle faces, references self.vertices - self.faces = faces + + # (n, 3) float array of vertices + self.vertices = vertices + + # (m, 3) int of triangle faces that references self.vertices + self.faces = faces # hold visual information about the mesh (vertex and face colors) if visual is None: @@ -185,9 +184,8 @@ def __init__( # convenience class for nearest point queries self.nearest = proximity.ProximityQuery(self) - # store metadata about the mesh in a dictionary - self.metadata = {} # update the mesh metadata with passed metadata + self.metadata = {} if isinstance(metadata, dict): self.metadata.update(metadata) elif metadata is not None: @@ -288,7 +286,7 @@ def mutable(self, value: bool) -> None: self._data.mutable = value @property - def faces(self) -> NDArray[int64]: + def faces(self) -> TrackedArray: """ The faces of the mesh. @@ -302,10 +300,10 @@ def faces(self) -> NDArray[int64]: faces : (n, 3) int64 References for `self.vertices` for triangles. """ - return self._data.get("faces", np.empty(shape=(0, 3), dtype=int64)) + return self._data["faces"] @faces.setter - def faces(self, values: Union[List[List[int]], NDArray[int64]]): + def faces(self, values: Optional[ArrayLike]) -> None: """ Set the vertex indexes that make up triangular faces. @@ -314,15 +312,17 @@ def faces(self, values: Union[List[List[int]], NDArray[int64]]): values : (n, 3) int64 Indexes of self.vertices """ - if values is None or len(values) == 0: - return self._data.data.pop("faces", None) - if not (isinstance(values, np.ndarray) and values.dtype == int64): + if values is None: + # if passed none store an empty array + values = np.empty(shape=(0, 3), dtype=int64) + else: values = np.asanyarray(values, dtype=int64) # automatically triangulate quad faces if len(values.shape) == 2 and values.shape[1] != 3: log.info("triangulating faces") values = geometry.triangulate_quads(values) + self._data["faces"] = values @caching.cache_decorator @@ -337,8 +337,7 @@ def faces_sparse(self) -> coo_matrix: dtype : bool shape : (len(self.vertices), len(self.faces)) """ - sparse = geometry.index_sparse(columns=len(self.vertices), indices=self.faces) - return sparse + return geometry.index_sparse(columns=len(self.vertices), indices=self.faces) @property def face_normals(self) -> NDArray[float64]: @@ -363,7 +362,7 @@ def face_normals(self) -> NDArray[float64]: # if we have no faces exit early if faces is None or len(faces) == 0: - return np.array([], dtype=int64).reshape((0, 3)) + return np.array([], dtype=float64).reshape((0, 3)) # if the shape of cached normals equals the shape of faces return if np.shape(cached) == np.shape(faces): @@ -393,14 +392,14 @@ def face_normals(self) -> NDArray[float64]: return padded @face_normals.setter - def face_normals(self, values: NDArray[float64]) -> None: + def face_normals(self, values: Optional[ArrayLike]) -> None: """ Assign values to face normals. Parameters ------------- values : (len(self.faces), 3) float - Unit face normals + Unit face normals. If None will clear existing normals. """ # if nothing passed exit if values is None: @@ -433,7 +432,7 @@ def face_normals(self, values: NDArray[float64]) -> None: self._cache["face_normals"] = values @property - def vertices(self) -> NDArray[float64]: + def vertices(self) -> TrackedArray: """ The vertices of the mesh. @@ -447,10 +446,11 @@ def vertices(self) -> NDArray[float64]: vertices : (n, 3) float Points in cartesian space referenced by self.faces """ - return self._data.get("vertices", np.empty(shape=(0, 3), dtype=float64)) + # get vertices if already stored + return self._data["vertices"] @vertices.setter - def vertices(self, values: ArrayLike): + def vertices(self, values: Optional[ArrayLike]): """ Assign vertex values to the mesh. @@ -459,8 +459,9 @@ def vertices(self, values: ArrayLike): values : (n, 3) float Points in space """ - if values is None or len(values) == 0: - return self._data.data.pop("vertices", None) + if values is None: + # remove any stored data and store an empty array + values = np.empty(shape=(0, 3), dtype=float64) self._data["vertices"] = np.asanyarray(values, order="C", dtype=float64) @caching.cache_decorator @@ -481,13 +482,12 @@ def vertex_normals(self) -> NDArray[float64]: """ # make sure we have faces_sparse assert hasattr(self.faces_sparse, "dot") - vertex_normals = geometry.weighted_vertex_normals( + return geometry.weighted_vertex_normals( vertex_count=len(self.vertices), faces=self.faces, face_normals=self.face_normals, face_angles=self.face_angles, ) - return vertex_normals @vertex_normals.setter def vertex_normals(self, values: ArrayLike) -> None: @@ -601,7 +601,7 @@ def center_mass(self) -> NDArray[float64]: return self.mass_properties.center_mass @center_mass.setter - def center_mass(self, value: NDArray[float64]) -> None: + def center_mass(self, value: ArrayLike) -> None: """ Override the point in space which is the center of mass and volume. @@ -626,10 +626,10 @@ def density(self) -> float: density The density of the primitive. """ - return self.mass_properties.density + return float(self.mass_properties.density) @density.setter - def density(self, value: float) -> None: + def density(self, value: Number) -> None: """ Set the density of the primitive. @@ -687,7 +687,7 @@ def moment_inertia(self) -> NDArray[float64]: """ return self.mass_properties.inertia - def moment_inertia_frame(self, transform: NDArray[float64]) -> NDArray[float64]: + def moment_inertia_frame(self, transform: ArrayLike) -> NDArray[float64]: """ Get the moment of inertia of this mesh with respect to an arbitrary frame, versus with respect to the center @@ -801,7 +801,7 @@ def symmetry(self) -> Optional[str]: return symmetry @property - def symmetry_axis(self) -> NDArray[float64]: + def symmetry_axis(self) -> Optional[NDArray[float64]]: """ If a mesh has rotational symmetry, return the axis. @@ -810,11 +810,12 @@ def symmetry_axis(self) -> NDArray[float64]: axis : (3, ) float Axis around which a 2D profile was revolved to create this mesh. """ - if self.symmetry is not None: - return self._cache["symmetry_axis"] + if self.symmetry is None: + return None + return self._cache["symmetry_axis"] @property - def symmetry_section(self) -> NDArray[float64]: + def symmetry_section(self) -> Optional[NDArray[float64]]: """ If a mesh has rotational symmetry return the two vectors which make up a section coordinate frame. @@ -824,8 +825,9 @@ def symmetry_section(self) -> NDArray[float64]: section : (2, 3) float Vectors to take a section along """ - if self.symmetry is not None: - return self._cache["symmetry_section"] + if self.symmetry is None: + return None + return self._cache["symmetry_section"] @caching.cache_decorator def triangles(self) -> NDArray[float64]: @@ -1062,13 +1064,12 @@ def euler_number(self) -> int: euler_number : int Topological invariant """ - euler = int( + return int( self.referenced_vertices.sum() - len(self.edges_unique) + len(self.faces) ) - return euler @caching.cache_decorator - def referenced_vertices(self) -> NDArray[bool]: + def referenced_vertices(self) -> NDArray[np.bool_]: """ Which vertices in the current mesh are referenced by a face. @@ -1081,25 +1082,6 @@ def referenced_vertices(self) -> NDArray[bool]: referenced[self.faces] = True return referenced - @property - def units(self) -> Optional[str]: - """ - Definition of units for the mesh. - - Returns - ---------- - units : str - Unit system mesh is in, or None if not defined - """ - return self.metadata.get("units", None) - - @units.setter - def units(self, value: str) -> None: - """ - Define the units of the current mesh. - """ - self.metadata["units"] = str(value).lower() - def convert_units(self, desired: str, guess: bool = False) -> "Trimesh": """ Convert the units of the mesh into a specified unit. @@ -1290,7 +1272,7 @@ def remove_infinite_values(self) -> None: vertex_mask = np.isfinite(self.vertices).all(axis=1) self.update_vertices(vertex_mask) - def unique_faces(self) -> NDArray[bool]: + def unique_faces(self) -> NDArray[np.bool_]: """ On the current mesh find which faces are unique. @@ -1456,7 +1438,7 @@ def face_adjacency_projections(self) -> NDArray[float64]: return projections @caching.cache_decorator - def face_adjacency_convex(self) -> NDArray[bool]: + def face_adjacency_convex(self) -> NDArray[np.bool_]: """ Return faces which are adjacent and locally convex. @@ -1689,7 +1671,7 @@ def remove_degenerate_faces(self, height: float = tol.merge) -> None: ) self.update_faces(self.nondegenerate_faces(height=height)) - def nondegenerate_faces(self, height: float = tol.merge) -> NDArray[bool]: + def nondegenerate_faces(self, height: float = tol.merge) -> NDArray[np.bool_]: """ Identify degenerate faces (faces without 3 unique vertex indices) in the current mesh. @@ -1806,7 +1788,7 @@ def facets_boundary(self) -> List[NDArray[int64]]: return edges_boundary @caching.cache_decorator - def facets_on_hull(self) -> NDArray[bool]: + def facets_on_hull(self) -> NDArray[np.bool_]: """ Find which facets of the mesh are on the convex hull. @@ -1868,7 +1850,7 @@ def fill_holes(self) -> bool: """ return repair.fill_holes(self) - def register(self, other: Geometry3D, **kwargs): + def register(self, other: Union[Geometry3D, NDArray], **kwargs): """ Align a mesh with another mesh or a PointCloud using the principal axes of inertia as a starting point which @@ -1876,8 +1858,6 @@ def register(self, other: Geometry3D, **kwargs): Parameters ------------ - mesh : trimesh.Trimesh object - Mesh to align with other other : trimesh.Trimesh or (n, 3) float Mesh or points in space samples : int @@ -1955,14 +1935,14 @@ def compute_stable_poses( threshold=threshold, ) - def subdivide(self, face_index: None = None) -> "Trimesh": + def subdivide(self, face_index: Optional[ArrayLike] = None) -> "Trimesh": """ - Subdivide a mesh, with each subdivided face replaced with four - smaller faces. + Subdivide a mesh, with each subdivided face replaced + with four smaller faces. Parameters ------------ - face_index: (m, ) int or None + face_index : (m, ) int or None If None all faces of mesh will be subdivided If (m, ) int array of indices: only specified faces will be subdivided. Note that in this case the mesh will generally @@ -1971,8 +1951,6 @@ def subdivide(self, face_index: None = None) -> "Trimesh": and an additional postprocessing step will be required to make resulting mesh watertight """ - # subdivide vertex attributes - vertex_attributes = {} visual = None if hasattr(self.visual, "uv") and np.shape(self.visual.uv) == ( len(self.vertices), @@ -1983,7 +1961,7 @@ def subdivide(self, face_index: None = None) -> "Trimesh": vertices=np.hstack((self.vertices, self.visual.uv)), faces=self.faces, face_index=face_index, - vertex_attributes=vertex_attributes, + vertex_attributes=self.vertex_attributes, ) # get a copy of the current visuals @@ -1998,7 +1976,7 @@ def subdivide(self, face_index: None = None) -> "Trimesh": vertices=self.vertices, faces=self.faces, face_index=face_index, - vertex_attributes=vertex_attributes, + vertex_attributes=self.vertex_attributes, ) # create a new mesh @@ -2434,7 +2412,7 @@ def unmerge_vertices(self) -> None: # keep face normals as the haven't changed self._cache.clear(exclude=["face_normals"]) - def apply_transform(self, matrix: NDArray[float64]) -> "Trimesh": + def apply_transform(self, matrix: ArrayLike) -> "Trimesh": """ Transform mesh by a homogeneous transformation matrix. @@ -2767,7 +2745,9 @@ def show(self, **kwargs): scene = self.scene() return scene.show(**kwargs) - def submesh(self, faces_sequence: List[NDArray[int64]], **kwargs): + def submesh( + self, faces_sequence: Sequence[ArrayLike], **kwargs + ) -> Union["Trimesh", List["Trimesh"]]: """ Return a subset of the mesh. @@ -2819,12 +2799,7 @@ def export( file_obj=None, file_type: Optional[str] = None, **kwargs, - ) -> Union[ - Dict[str, Union[Dict[str, str], List[List[int]], List[List[float]]]], - str, - bytes, - Dict[str, Union[Dict[str, str], Dict[str, Union[str, Tuple[int, int]]]]], - ]: + ): """ Export the current mesh to a file object. If file_obj is a filename, file will be written there. @@ -2983,7 +2958,7 @@ def intersection( **kwargs, ) - def contains(self, points: ArrayLike) -> NDArray[bool]: + def contains(self, points: ArrayLike) -> NDArray[np.bool_]: """ Given an array of points determine whether or not they are inside the mesh. This raises an error if called on a diff --git a/trimesh/constants.py b/trimesh/constants.py index 07acc2c36..28d4cdcf1 100644 --- a/trimesh/constants.py +++ b/trimesh/constants.py @@ -90,14 +90,14 @@ class TolerancePath: merge: float = 1e-5 planar: float = 1e-5 seg_frac: float = 0.125 - seg_angle: float = np.radians(50) - seg_angle_min: float = np.radians(1) + seg_angle: float = float(np.radians(50)) + seg_angle_min: float = float(np.radians(1)) seg_angle_frac: float = 0.5 aspect_frac: float = 0.1 radius_frac: float = 0.02 radius_min: float = 1e-4 radius_max: float = 50.0 - tangent: float = np.radians(20) + tangent: float = float(np.radians(20)) strict: bool = False @@ -122,8 +122,8 @@ class ResolutionPath: seg_frac: float = 0.05 seg_angle: float = 0.08 - max_sections: float = 500 - min_sections: float = 20 + max_sections: float = 500.0 + min_sections: float = 20.0 export: str = "0.10f" diff --git a/trimesh/creation.py b/trimesh/creation.py index 60e218c30..e7afcec23 100644 --- a/trimesh/creation.py +++ b/trimesh/creation.py @@ -15,7 +15,7 @@ from .base import Trimesh from .constants import log, tol from .geometry import align_vectors, faces_to_edges, plane_transform -from .typed import ArrayLike, Dict, NDArray, Optional, float64 +from .typed import ArrayLike, Dict, Integer, Number, Optional try: # shapely is a soft dependency @@ -35,11 +35,11 @@ def revolve( linestring: ArrayLike, - angle: Optional[float] = None, - sections: Optional[int] = None, - transform: Optional[NDArray] = None, + angle: Optional[Number] = None, + sections: Optional[Integer] = None, + transform: Optional[ArrayLike] = None, **kwargs, -): +) -> Trimesh: """ Revolve a 2D line string around the 2D Y axis, with a result with the 2D Y axis pointing along the 3D Z axis. @@ -173,8 +173,8 @@ def revolve( def extrude_polygon( - polygon: "Polygon", height: float, transform: Optional[NDArray] = None, **kwargs -): + polygon: "Polygon", height: Number, transform: Optional[ArrayLike] = None, **kwargs +) -> Trimesh: """ Extrude a 2D shapely polygon into a 3D mesh @@ -206,7 +206,7 @@ def extrude_polygon( def sweep_polygon( - polygon: "Polygon", path: ArrayLike, angles: Optional[NDArray] = None, **kwargs + polygon: "Polygon", path: ArrayLike, angles: Optional[ArrayLike] = None, **kwargs ): """ Extrude a 2D shapely polygon into a 3D mesh along an @@ -315,10 +315,10 @@ def sweep_polygon( def extrude_triangulation( vertices: ArrayLike, faces: ArrayLike, - height: float, - transform: Optional[NDArray] = None, + height: Number, + transform: Optional[ArrayLike] = None, **kwargs, -): +) -> Trimesh: """ Extrude a 2D triangulation into a watertight mesh. @@ -404,10 +404,13 @@ def extrude_triangulation( return mesh -def triangulate_polygon(polygon: Polygon, triangle_args=None, engine=None, **kwargs): +def triangulate_polygon( + polygon, triangle_args: Optional[str] = None, engine: Optional[str] = None, **kwargs +): """ Given a shapely polygon create a triangulation using a - python interface to `triangle.c` or mapbox-earcut. + python interface to the permissively licensed `mapbox-earcut` + or the more robust `triangle.c`. > pip install triangle > pip install mapbox_earcut @@ -416,9 +419,9 @@ def triangulate_polygon(polygon: Polygon, triangle_args=None, engine=None, **kwa polygon : Shapely.geometry.Polygon Polygon object to be triangulated. triangle_args : str or None - Passed to triangle.triangulate i.e: 'p', 'pq30' + Passed to triangle.triangulate i.e: 'p', 'pq30', 'pY'="don't insert vert" engine : None or str - Any value other than 'earcut' will use `triangle` + None or 'earcut' will use earcut, 'triangle' will use triangle Returns -------------- @@ -467,7 +470,7 @@ def triangulate_polygon(polygon: Polygon, triangle_args=None, engine=None, **kwa raise ValueError("no valid triangulation engine!") -def _polygon_to_kwargs(polygon: Polygon) -> Dict: +def _polygon_to_kwargs(polygon) -> Dict: """ Given a shapely polygon generate the data to pass to the triangle mesh generator @@ -559,13 +562,18 @@ def add_boundary(boundary, start): return result -def box(extents=None, transform: Optional[NDArray] = None, bounds=None, **kwargs): +def box( + extents: Optional[ArrayLike] = None, + transform: Optional[ArrayLike] = None, + bounds: Optional[ArrayLike] = None, + **kwargs, +): """ Return a cuboid. Parameters ------------ - extents : float, or (3,) float + extents : (3,) float Edge lengths transform: (4, 4) float Transformation matrix @@ -703,7 +711,7 @@ def box(extents=None, transform: Optional[NDArray] = None, bounds=None, **kwargs return box -def icosahedron(**kwargs): +def icosahedron(**kwargs) -> Trimesh: """ Create an icosahedron, one of the platonic solids which is has 20 faces. @@ -827,7 +835,7 @@ def icosahedron(**kwargs): ) -def icosphere(subdivisions: int = 3, radius: float = 1.0, **kwargs): +def icosphere(subdivisions: Integer = 3, radius: Number = 1.0, **kwargs): """ Create an isophere centered at the origin. @@ -877,11 +885,11 @@ def icosphere(subdivisions: int = 3, radius: float = 1.0, **kwargs): def uv_sphere( - radius: float = 1.0, + radius: Number = 1.0, count: Optional[ArrayLike] = None, - transform: Optional[NDArray] = None, + transform: Optional[ArrayLike] = None, **kwargs, -): +) -> Trimesh: """ Create a UV sphere (latitude + longitude) centered at the origin. Roughly one order of magnitude faster than an @@ -926,10 +934,10 @@ def uv_sphere( def capsule( - height: float = 1.0, - radius: float = 1.0, + height: Number = 1.0, + radius: Number = 1.0, count: Optional[ArrayLike] = None, - transform: Optional[NDArray] = None, + transform: Optional[ArrayLike] = None, ): """ Create a mesh of a capsule, or a cylinder with hemispheric ends. @@ -979,12 +987,12 @@ def capsule( def cone( - radius: float, - height: float, - sections: Optional[int] = None, - transform: Optional[NDArray] = None, + radius: Number, + height: Number, + sections: Optional[Integer] = None, + transform: Optional[ArrayLike] = None, **kwargs, -): +) -> Trimesh: """ Create a mesh of a cone along Z centered at the origin. @@ -1020,11 +1028,11 @@ def cone( def cylinder( - radius: float, - height: Optional[float] = None, - sections: Optional[int] = None, - segment=None, - transform: Optional[NDArray] = None, + radius: Number, + height: Optional[Number] = None, + sections: Optional[Integer] = None, + segment: Optional[ArrayLike] = None, + transform: Optional[ArrayLike] = None, **kwargs, ): """ @@ -1071,12 +1079,12 @@ def cylinder( def annulus( - r_min: float, - r_max: float, - height: Optional[float] = None, - sections: Optional[int] = None, - transform: Optional[NDArray] = None, - segment: Optional[NDArray] = None, + r_min: Number, + r_max: Number, + height: Optional[Number] = None, + sections: Optional[Integer] = None, + transform: Optional[ArrayLike] = None, + segment: Optional[ArrayLike] = None, **kwargs, ): """ @@ -1143,7 +1151,7 @@ def annulus( return annulus -def _segment_to_cylinder(segment: NDArray[float64]): +def _segment_to_cylinder(segment: ArrayLike): """ Convert a line segment to a transform and height for a cylinder or cylinder-like primitive. @@ -1177,7 +1185,7 @@ def _segment_to_cylinder(segment: NDArray[float64]): return transform, height -def random_soup(face_count: int = 100): +def random_soup(face_count: Integer = 100): """ Return random triangles as a Trimesh @@ -1198,11 +1206,11 @@ def random_soup(face_count: int = 100): def axis( - origin_size: float = 0.04, - transform: Optional[NDArray] = None, + origin_size: Number = 0.04, + transform: Optional[ArrayLike] = None, origin_color: Optional[ArrayLike] = None, - axis_radius: Optional[float] = None, - axis_length: Optional[float] = None, + axis_radius: Optional[Number] = None, + axis_length: Optional[Number] = None, ): """ Return an XYZ axis marker as a Trimesh, which represents position @@ -1284,7 +1292,7 @@ def axis( def camera_marker( - camera, marker_height: float = 0.4, origin_size: Optional[float] = None + camera, marker_height: Number = 0.4, origin_size: Optional[Number] = None ): """ Create a visual marker for a camera object, including an axis and FOV. @@ -1411,11 +1419,11 @@ def truncated_prisms( def torus( - major_radius: float, - minor_radius: float, - major_sections: int = 32, - minor_sections: int = 32, - transform: Optional[NDArray] = None, + major_radius: Number, + minor_radius: Number, + major_sections: Integer = 32, + minor_sections: Integer = 32, + transform: Optional[ArrayLike] = None, **kwargs, ): """Create a mesh of a torus around Z centered at the origin. diff --git a/trimesh/decomposition.py b/trimesh/decomposition.py index 5793ae52b..c467b0c4d 100644 --- a/trimesh/decomposition.py +++ b/trimesh/decomposition.py @@ -1,7 +1,7 @@ -from typing import Dict, List - import numpy as np +from .typed import Dict, List + def convex_decomposition(mesh, **kwargs) -> List[Dict]: """ diff --git a/trimesh/exchange/cascade.py b/trimesh/exchange/cascade.py index d343b2ef9..5e8c4cedb 100644 --- a/trimesh/exchange/cascade.py +++ b/trimesh/exchange/cascade.py @@ -1,7 +1,7 @@ import os import tempfile -from ..typed import BinaryIO, Dict, Optional +from ..typed import BinaryIO, Dict, Number, Optional # used as an intermediate format from .gltf import load_glb @@ -10,8 +10,8 @@ def load_step( file_obj: BinaryIO, file_type, - tol_linear: Optional[float] = None, - tol_angular: Optional[float] = None, + tol_linear: Optional[Number] = None, + tol_angular: Optional[Number] = None, tol_relative: Optional[bool] = False, merge_primitives: bool = True, **kwargs, diff --git a/trimesh/exchange/load.py b/trimesh/exchange/load.py index 3daf81413..8922bb058 100644 --- a/trimesh/exchange/load.py +++ b/trimesh/exchange/load.py @@ -9,7 +9,7 @@ from ..parent import Geometry from ..points import PointCloud from ..scene.scene import Scene, append_scenes -from ..typed import List, Loadable, Optional, Union +from ..typed import Dict, List, Loadable, Optional, Union from ..util import log, now from . import misc from .binvox import _binvox_loaders @@ -71,10 +71,10 @@ def available_formats() -> set: def load( file_obj: Loadable, file_type: Optional[str] = None, - resolver: Optional[resolvers.Resolver] = None, + resolver: Union[resolvers.Resolver, Dict, None] = None, force: Optional[str] = None, **kwargs, -) -> Geometry: +) -> Union[Geometry, List[Geometry]]: """ Load a mesh or vectorized path into objects like Trimesh, Path2D, Path3D, Scene @@ -164,7 +164,7 @@ def load( def load_mesh( file_obj: Loadable, file_type: Optional[str] = None, - resolver: Optional[resolvers.Resolver] = None, + resolver: Union[resolvers.Resolver, Dict, None] = None, **kwargs, ) -> Union[Geometry, List[Geometry]]: """ @@ -508,7 +508,7 @@ def handle_pointcloud(): def _parse_file_args( file_obj: Loadable, file_type: Optional[str], - resolver: Optional[resolvers.Resolver] = None, + resolver: Union[None, Dict, resolvers.Resolver] = None, **kwargs, ): """ diff --git a/trimesh/geometry.py b/trimesh/geometry.py index 3c79c93cf..7392ac658 100644 --- a/trimesh/geometry.py +++ b/trimesh/geometry.py @@ -2,6 +2,7 @@ from . import util from .constants import log +from .typed import NDArray try: import scipy.sparse @@ -102,7 +103,7 @@ def faces_to_edges(faces, return_index=False): edges : (n*3, 2) int Vertex indices representing edges """ - faces = np.asanyarray(faces) + faces = np.asanyarray(faces, np.int64) # each face has three edges edges = faces[:, [0, 1, 1, 2, 2, 0]].reshape((-1, 2)) @@ -146,7 +147,7 @@ def vector_angle(pairs): return angles -def triangulate_quads(quads, dtype=np.int64): +def triangulate_quads(quads, dtype=np.int64) -> NDArray: """ Given an array of quad faces return them as triangle faces, also handles pure triangles and mixed triangles and quads. diff --git a/trimesh/graph.py b/trimesh/graph.py index dd14d891d..22f77c1b1 100644 --- a/trimesh/graph.py +++ b/trimesh/graph.py @@ -16,7 +16,7 @@ from . import exceptions, grouping, util from .constants import log, tol from .geometry import faces_to_edges -from .typed import List, Optional +from .typed import List, NDArray, Number, Optional, int64 try: from scipy.sparse import coo_matrix, csgraph @@ -107,10 +107,11 @@ def face_adjacency(faces=None, mesh=None, return_edges=False): adjacency_edges = edges[edge_groups[:, 0][nondegenerate]] assert len(adjacency_edges) == len(adjacency) return adjacency, adjacency_edges + return adjacency -def face_neighborhood(mesh): +def face_neighborhood(mesh) -> NDArray[int64]: """ Find faces that share a vertex i.e. 'neighbors' faces. Relies on the fact that an adjacency matrix at a power p @@ -129,7 +130,9 @@ def face_neighborhood(mesh): TT.setdiag(0) TT.eliminate_zeros() TT = TT.tocoo() - neighborhood = np.concatenate((TT.row[:, None], TT.col[:, None]), axis=-1) + neighborhood = np.concatenate( + (TT.row[:, None], TT.col[:, None]), axis=-1, dtype=np.int64 + ) return neighborhood @@ -757,7 +760,7 @@ def smoothed(*args, **kwargs): def smooth_shade( - mesh, angle: Optional[float] = None, facet_minarea: Optional[float] = 10.0 + mesh, angle: Optional[Number] = None, facet_minarea: Optional[Number] = 10.0 ): """ Return a non-watertight version of the mesh which diff --git a/trimesh/grouping.py b/trimesh/grouping.py index 661f5a9b4..84c8934be 100644 --- a/trimesh/grouping.py +++ b/trimesh/grouping.py @@ -9,7 +9,7 @@ from . import util from .constants import log, tol -from .typed import ArrayLike, NDArray, Optional, Union +from .typed import ArrayLike, Integer, NDArray, Optional try: from scipy.spatial import cKDTree @@ -157,9 +157,7 @@ def group(values, min_len=0, max_len=np.inf): return groups -def hashable_rows( - data: ArrayLike, digits=None -) -> Union[NDArray[np.uint64], NDArray[np.void]]: +def hashable_rows(data: ArrayLike, digits=None) -> NDArray: """ We turn our array into integers based on the precision given by digits and then put them in a hashable format. @@ -179,7 +177,7 @@ def hashable_rows( """ # if there is no data return immediately if len(data) == 0: - return np.array([]) + return np.array([], dtype=np.uint64) # get array as integer to precision we care about as_int = float_to_int(data, digits=digits) @@ -223,7 +221,7 @@ def hashable_rows( return result -def float_to_int(data, digits: Optional[int] = None): +def float_to_int(data, digits: Optional[Integer] = None): """ Given a numpy array of float/bool/int, return as integers. diff --git a/trimesh/inertia.py b/trimesh/inertia.py index 24bda99be..94b836f88 100644 --- a/trimesh/inertia.py +++ b/trimesh/inertia.py @@ -94,7 +94,7 @@ def principal_axis(inertia): # you could any of the following to calculate this: # np.linalg.svd, np.linalg.eig, np.linalg.eigh # moment of inertia is square symmetric matrix - # eigh has the best numeric precision in tests + # eigh has the best precision in tests components, vectors = np.linalg.eigh(inertia) # eigh returns them as column vectors, change them to row vectors diff --git a/trimesh/interfaces/blender.py b/trimesh/interfaces/blender.py index 9136920a9..906fe5ffc 100644 --- a/trimesh/interfaces/blender.py +++ b/trimesh/interfaces/blender.py @@ -3,14 +3,13 @@ from .. import resources, util from ..constants import log -from ..typed import Sequence +from ..typed import Iterable from .generic import MeshScript -_search_path = os.environ.get("PATH", "") if platform.system() == "Windows": # try to find Blender install on Windows # split existing path by delimiter - _search_path = [i for i in _search_path.split(";") if len(i) > 0] + _search_path = [i for i in os.environ.get("PATH", "").split(";") if len(i) > 0] for pf in [r"C:\Program Files", r"C:\Program Files (x86)"]: pf = os.path.join(pf, "Blender Foundation") if os.path.exists(pf): @@ -19,22 +18,27 @@ _search_path.append(os.path.join(pf, p)) _search_path = ";".join(set(_search_path)) log.debug("searching for blender in: %s", _search_path) - -if platform.system() == "Darwin": +elif platform.system() == "Darwin": # try to find Blender on Mac OSX - _search_path = [i for i in _search_path.split(":") if len(i) > 0] - _search_path.append("/Applications/blender.app/Contents/MacOS") - _search_path.append("/Applications/Blender.app/Contents/MacOS") - _search_path.append("/Applications/Blender/blender.app/Contents/MacOS") + _search_path = [i for i in os.environ.get("PATH", "").split(":") if len(i) > 0] + _search_path.extend( + [ + "/Applications/blender.app/Contents/MacOS", + "/Applications/Blender.app/Contents/MacOS", + "/Applications/Blender/blender.app/Contents/MacOS", + ] + ) _search_path = ":".join(set(_search_path)) log.debug("searching for blender in: %s", _search_path) +else: + _search_path = os.environ.get("PATH", "") _blender_executable = util.which("blender", path=_search_path) exists = _blender_executable is not None def boolean( - meshes: Sequence, + meshes: Iterable, operation: str = "difference", use_exact: bool = False, use_self: bool = False, @@ -80,11 +84,12 @@ def boolean( with MeshScript(meshes=meshes, script=script, debug=debug) as blend: result = blend.run(_blender_executable + " --background --python $SCRIPT") - for m in util.make_sequence(result): + result = util.make_sequence(result) + for m in result: # blender returns actively incorrect face normals m.face_normals = None - return result + return util.concatenate(result) def unwrap( @@ -97,7 +102,7 @@ def unwrap( raise ValueError("No blender available!") # get the template from our resources folder - template = resources.get_string("templates/blender_unwrap.py") + template = resources.get_string("templates/blender_unwrap.py.template") script = template.replace("$ANGLE_LIMIT", "%.6f" % angle_limit).replace( "$ISLAND_MARGIN", "%.6f" % island_margin ) diff --git a/trimesh/interval.py b/trimesh/interval.py index 73782d5bf..1dbc98362 100644 --- a/trimesh/interval.py +++ b/trimesh/interval.py @@ -8,10 +8,10 @@ import numpy as np -from .typed import NDArray, float64 +from .typed import ArrayLike, NDArray, float64 -def intersection(a: NDArray[float64], b: NDArray[float64]) -> NDArray[float64]: +def intersection(a: ArrayLike, b: NDArray[float64]) -> NDArray[float64]: """ Given pairs of ranges merge them in to one range if they overlap. @@ -59,7 +59,7 @@ def intersection(a: NDArray[float64], b: NDArray[float64]) -> NDArray[float64]: return overlap -def union(intervals: NDArray[float64], sort: bool = True) -> NDArray[float64]: +def union(intervals: ArrayLike, sort: bool = True) -> NDArray[float64]: """ For array of multiple intervals union them all into the subset of intervals. diff --git a/trimesh/parent.py b/trimesh/parent.py index 11149eac1..45bb72b5b 100644 --- a/trimesh/parent.py +++ b/trimesh/parent.py @@ -13,6 +13,7 @@ from . import transformations as tf from .caching import cache_decorator from .constants import tol +from .typed import Dict, Optional from .util import ABC @@ -25,6 +26,9 @@ class Geometry(ABC): those methods. """ + # geometry should have a dict to store loose metadata + metadata: Dict + @property @abc.abstractmethod def bounds(self): @@ -39,6 +43,7 @@ def extents(self): def apply_transform(self, matrix): pass + @property @abc.abstractmethod def is_empty(self) -> bool: pass @@ -181,6 +186,25 @@ def scale(self) -> float: return scale + @property + def units(self) -> Optional[str]: + """ + Definition of units for the mesh. + + Returns + ---------- + units : str + Unit system mesh is in, or None if not defined + """ + return self.metadata.get("units", None) + + @units.setter + def units(self, value: str) -> None: + """ + Define the units of the current mesh. + """ + self.metadata["units"] = str(value).lower().strip() + class Geometry3D(Geometry): """ diff --git a/trimesh/path/arc.py b/trimesh/path/arc.py index fff7c1a59..5ac765e62 100644 --- a/trimesh/path/arc.py +++ b/trimesh/path/arc.py @@ -6,7 +6,7 @@ from ..constants import log from ..constants import res_path as res from ..constants import tol_path as tol -from ..typed import ArrayLike, NDArray, Optional, float64 +from ..typed import ArrayLike, NDArray, Number, Optional, float64 # floating point zero _TOL_ZERO = 1e-12 @@ -28,7 +28,7 @@ class ArcInfo: angles: Optional[NDArray[float64]] = None # what is the angular span of this circular arc. - span: Optional[float] = None + span: Optional[Number] = None def __getitem__(self, item): # add for backwards compatibility diff --git a/trimesh/path/exchange/svg_io.py b/trimesh/path/exchange/svg_io.py index 1c0cd874d..0e207a8ab 100644 --- a/trimesh/path/exchange/svg_io.py +++ b/trimesh/path/exchange/svg_io.py @@ -670,7 +670,7 @@ def _deep_same(original, other): assert original == other return elif isinstance(original, (float, int, np.ndarray)): - # for numeric classes use numpy magic comparison + # for Number classes use numpy magic comparison # which includes an epsilon for floating point assert np.allclose(original, other) return diff --git a/trimesh/path/packing.py b/trimesh/path/packing.py index 6c9db7d5f..716a3a43a 100644 --- a/trimesh/path/packing.py +++ b/trimesh/path/packing.py @@ -5,11 +5,10 @@ Pack rectangular regions onto larger rectangular regions. """ -from typing import Optional - import numpy as np from ..constants import log, tol +from ..typed import Integer, Number, Optional from ..util import allclose, bounds_tree # floating point zero @@ -511,9 +510,9 @@ def images( images, power_resize: bool = False, deduplicate: bool = False, - iterations: Optional[int] = 50, - seed: Optional[int] = None, - spacing: Optional[float] = None, + iterations: Optional[Integer] = 50, + seed: Optional[Integer] = None, + spacing: Optional[Number] = None, mode: Optional[str] = None, ): """ diff --git a/trimesh/path/path.py b/trimesh/path/path.py index a5589f5be..7d615e18c 100644 --- a/trimesh/path/path.py +++ b/trimesh/path/path.py @@ -18,7 +18,7 @@ from ..constants import tol_path as tol from ..geometry import plane_transform from ..points import plane_fit -from ..typed import Dict, Iterable, List, NDArray, Optional, float64 +from ..typed import ArrayLike, Dict, Iterable, List, NDArray, Optional, float64 from ..visual import to_rgba from . import ( creation, # NOQA @@ -73,7 +73,7 @@ class Path(parent.Geometry): def __init__( self, entities: Optional[Iterable[Entity]] = None, - vertices: Optional[NDArray[float64]] = None, + vertices: Optional[ArrayLike] = None, metadata: Optional[Dict] = None, process: bool = True, colors=None, @@ -170,12 +170,15 @@ def colors(self, values): e.color = c @property - def vertices(self): + def vertices(self) -> NDArray[float64]: return self._vertices @vertices.setter - def vertices(self, values: NDArray[float64]): - self._vertices = caching.tracked_array(values, dtype=np.float64) + def vertices(self, values: Optional[ArrayLike]): + if values is None: + self._vertices = caching.tracked_array([], dtype=np.float64) + else: + self._vertices = caching.tracked_array(values, dtype=np.float64) @property def entities(self): @@ -327,26 +330,7 @@ def extents(self): """ return self.bounds.ptp(axis=0) - @property - def units(self): - """ - If there are units defined in self.metadata return them. - - Returns - ----------- - units : str - Current unit system - """ - if "units" in self.metadata: - return self.metadata["units"] - else: - return None - - @units.setter - def units(self, units): - self.metadata["units"] = units - - def convert_units(self, desired, guess=False): + def convert_units(self, desired: str, guess: bool = False): """ Convert the units of the current drawing in place. diff --git a/trimesh/path/polygons.py b/trimesh/path/polygons.py index 4c051fd9e..3edf453fb 100644 --- a/trimesh/path/polygons.py +++ b/trimesh/path/polygons.py @@ -6,7 +6,7 @@ from ..constants import log from ..constants import tol_path as tol from ..transformations import transform_points -from ..typed import List, NDArray, Optional, float64 +from ..typed import Iterable, NDArray, Number, Optional, Union, float64 from .simplify import fit_circle_check from .traversal import resample_path @@ -164,7 +164,7 @@ def edges_to_polygons(edges, vertices): return complete -def polygons_obb(polygons: List[Polygon]): +def polygons_obb(polygons: Iterable[Polygon]): """ Find the OBBs for a list of shapely.geometry.Polygons """ @@ -175,7 +175,7 @@ def polygons_obb(polygons: List[Polygon]): return np.array(transforms), np.array(rectangles) -def polygon_obb(polygon: Polygon): +def polygon_obb(polygon: Union[Polygon, NDArray]): """ Find the oriented bounding box of a Shapely polygon. @@ -368,7 +368,7 @@ def stack_boundaries(boundaries): return np.vstack((boundaries["shell"], np.vstack(boundaries["holes"]))) -def medial_axis(polygon: Polygon, resolution: Optional[float] = None, clip=None): +def medial_axis(polygon: Polygon, resolution: Optional[Number] = None, clip=None): """ Given a shapely polygon, find the approximate medial axis using a voronoi diagram of evenly spaced points on the diff --git a/trimesh/path/segments.py b/trimesh/path/segments.py index 3224214ab..b8a0fc2ef 100644 --- a/trimesh/path/segments.py +++ b/trimesh/path/segments.py @@ -11,10 +11,10 @@ from ..constants import tol from ..grouping import group_rows, unique_rows from ..interval import union -from ..typed import NDArray, float64 +from ..typed import ArrayLike, NDArray, float64 -def segments_to_parameters(segments: NDArray[float64]): +def segments_to_parameters(segments: ArrayLike): """ For 3D line segments defined by two points, turn them in to an origin defined as the closest point along @@ -60,7 +60,7 @@ def segments_to_parameters(segments: NDArray[float64]): def parameters_to_segments( - origins: NDArray[float64], vectors: NDArray[float64], parameters: NDArray[float64] + origins: NDArray[float64], vectors: ArrayLike, parameters: NDArray[float64] ): """ Convert a parametric line segment representation to @@ -161,7 +161,7 @@ def colinear_pairs(segments, radius=0.01, angle=0.01, length=None): return colinear -def clean(segments: NDArray[float64], digits: int = 10) -> NDArray[float64]: +def clean(segments: ArrayLike, digits: int = 10) -> NDArray[float64]: """ Clean up line segments by unioning the ranges of colinear segments. diff --git a/trimesh/path/traversal.py b/trimesh/path/traversal.py index ec6ed2657..20fb382e0 100644 --- a/trimesh/path/traversal.py +++ b/trimesh/path/traversal.py @@ -205,7 +205,7 @@ def discretize_path(entities, vertices, path, scale=1.0): Indexes of entities scale : float Overall scale of drawing used for - numeric tolerances in certain cases + Number tolerances in certain cases Returns ----------- diff --git a/trimesh/points.py b/trimesh/points.py index 55372230d..f5cf44f7a 100644 --- a/trimesh/points.py +++ b/trimesh/points.py @@ -414,7 +414,6 @@ def __init__(self, vertices, colors=None, metadata=None, **kwargs): self._data = caching.DataStore() self._cache = caching.Cache(self._data.__hash__) self.metadata = {} - if metadata is not None: self.metadata.update(metadata) diff --git a/trimesh/repair.py b/trimesh/repair.py index b2a6c69da..1672f4814 100644 --- a/trimesh/repair.py +++ b/trimesh/repair.py @@ -101,7 +101,6 @@ def fix_inversion(mesh, multibody=False): # this will make things worse for non-watertight meshes return - if multibody: groups = graph.connected_components(mesh.face_adjacency) # escape early for single body diff --git a/trimesh/resources/__init__.py b/trimesh/resources/__init__.py index 039a89399..f0c66241e 100644 --- a/trimesh/resources/__init__.py +++ b/trimesh/resources/__init__.py @@ -2,7 +2,7 @@ import os import warnings -from ..typed import IO, Dict +from ..typed import Dict, Stream from ..util import decode_text, wrap_as_stream # find the current absolute path to this directory @@ -150,7 +150,7 @@ def get_bytes(name: str) -> bytes: return _get(name, decode=False, decode_json=False, as_stream=False) -def get_stream(name: str) -> IO: +def get_stream(name: str) -> Stream: """ Get a resource from the `trimesh/resources` folder as a binary stream. diff --git a/trimesh/resources/templates/blender_unwrap.py.template b/trimesh/resources/templates/blender_unwrap.py.template new file mode 100644 index 000000000..d1879bef8 --- /dev/null +++ b/trimesh/resources/templates/blender_unwrap.py.template @@ -0,0 +1,40 @@ +# flake8: noqa + +import bpy +from bl_operators.uvcalc_smart_project import main as smart_proj +import os + + +if __name__ == '__main__': + # clear scene of default box + bpy.ops.wm.read_homefile() + try: + bpy.ops.object.mode_set(mode='OBJECT') + except BaseException: + pass + bpy.ops.object.select_all(action='SELECT') + bpy.ops.object.delete(use_global=True) + + # get temporary files from templated locations + mesh_pre = $MESH_PRE + mesh_post = os.path.abspath(r'$MESH_POST') + + # use data.objects instead of context.scene.objects + bpy.ops.import_scene.obj(filepath=os.path.abspath(mesh_pre[0])) + bpy.ops.object.select_all(action='SELECT') + + mesh = bpy.data.objects[0] + # Make sure mesh is the active object + try: + # earlier than blender <2.8 + bpy.context.scene.objects.active = mesh + except AttributeError: + # blender 2.8 changed this + bpy.context.view_layer.objects.active = mesh + + smart_proj(bpy.context, $ISLAND_MARGIN, $ANGLE_LIMIT, 0, True, True) + + bpy.ops.export_scene.obj( + filepath=mesh_post, + use_mesh_modifiers=False, + use_uvs=True) diff --git a/trimesh/sample.py b/trimesh/sample.py index 9a446b74e..1b777a487 100644 --- a/trimesh/sample.py +++ b/trimesh/sample.py @@ -8,19 +8,17 @@ import numpy as np from . import transformations, util +from .typed import ArrayLike, Integer, NDArray, Number, Optional, float64 from .visual import uv_to_interpolated_color -if hasattr(np.random, "default_rng"): - # newer versions of Numpy - default_rng = np.random.default_rng -else: - # Python 2 Numpy - class default_rng(np.random.RandomState): - def random(self, *args, **kwargs): - return self.random_sample(*args, **kwargs) - -def sample_surface(mesh, count, face_weight=None, sample_color=False, seed=None): +def sample_surface( + mesh, + count: Integer, + face_weight: Optional[ArrayLike] = None, + sample_color=False, + seed=None, +): """ Sample the surface of a mesh, returning the specified number of points @@ -64,7 +62,10 @@ def sample_surface(mesh, count, face_weight=None, sample_color=False, seed=None) weight_cum = np.cumsum(face_weight) # seed the random number generator as requested - random = default_rng(seed).random + if seed is None: + random = np.random.random + else: + random = np.random.default_rng(seed).random # last value of cumulative sum is total summed weight/area face_pick = random(count) * weight_cum[-1] @@ -120,7 +121,7 @@ def sample_surface(mesh, count, face_weight=None, sample_color=False, seed=None) return samples, face_index -def volume_mesh(mesh, count): +def volume_mesh(mesh, count: Integer) -> NDArray[float64]: """ Use rejection sampling to produce points randomly distributed in the volume of a mesh. @@ -144,7 +145,9 @@ def volume_mesh(mesh, count): return samples -def volume_rectangular(extents, count, transform=None): +def volume_rectangular( + extents, count: Integer, transform: Optional[ArrayLike] = None +) -> NDArray[float64]: """ Return random samples inside a rectangular volume, useful for sampling inside oriented bounding boxes. @@ -170,7 +173,7 @@ def volume_rectangular(extents, count, transform=None): return samples -def sample_surface_even(mesh, count, radius=None, seed=None): +def sample_surface_even(mesh, count: Integer, radius: Optional[Number] = None, seed=None): """ Sample the surface of a mesh, returning samples which are VERY approximately evenly spaced. This is accomplished by @@ -220,7 +223,7 @@ def sample_surface_even(mesh, count, radius=None, seed=None): return points, index[mask] -def sample_surface_sphere(count): +def sample_surface_sphere(count: int) -> NDArray[float64]: """ Correctly pick random points on the surface of a unit sphere diff --git a/trimesh/scene/scene.py b/trimesh/scene/scene.py index 987b05805..6162440d6 100644 --- a/trimesh/scene/scene.py +++ b/trimesh/scene/scene.py @@ -4,6 +4,7 @@ import numpy as np from .. import caching, convex, grouping, inertia, transformations, units, util +from ..constants import log from ..exchange import export from ..parent import Geometry, Geometry3D from ..typed import ( @@ -492,7 +493,7 @@ def moment_inertia_frame(self, transform): return inertia.scene_inertia(scene=self, transform=transform) @caching.cache_decorator - def area(self) -> float64: + def area(self) -> float: """ What is the summed area of every geometry which has area. @@ -538,9 +539,8 @@ def triangles(self) -> NDArray[float64]: triangles : (n, 3, 3) float Triangles in space """ - triangles = collections.deque() - triangles_node = collections.deque() - + triangles = [] + triangles_node = [] for node_name in self.graph.nodes_geometry: # which geometry does this node refer to transform, geometry_name = self.graph[node_name] @@ -559,8 +559,7 @@ def triangles(self) -> NDArray[float64]: triangles_node.append(np.tile(node_name, len(geometry.triangles))) # save the resulting nodes to the cache self._cache["triangles_node"] = np.hstack(triangles_node) - triangles = np.vstack(triangles).reshape((-1, 3, 3)) - return triangles + return np.vstack(triangles).reshape((-1, 3, 3)) @caching.cache_decorator def triangles_node(self): @@ -716,7 +715,7 @@ def camera_transform(self): return self.graph[self.camera.name][0] @camera_transform.setter - def camera_transform(self, matrix: NDArray[float64]): + def camera_transform(self, matrix: ArrayLike): """ Set the camera transform in the base frame @@ -995,6 +994,8 @@ def units(self) -> Optional[str]: existing = {i.units for i in self.geometry.values()} if len(existing) == 1: return existing.pop() + elif len(existing) > 1: + log.warning(f"Mixed units `{existing}` returning None") return None @units.setter @@ -1008,6 +1009,7 @@ def units(self, value: str): value : str Value to set every geometry unit value to """ + value = value.strip().lower() for m in self.geometry.values(): m.units = value diff --git a/trimesh/scene/transforms.py b/trimesh/scene/transforms.py index 1b3aef51e..7b006c3a9 100644 --- a/trimesh/scene/transforms.py +++ b/trimesh/scene/transforms.py @@ -1,12 +1,12 @@ import collections from copy import deepcopy -from typing import Sequence, Union import numpy as np from .. import caching, util from ..caching import hash_fast from ..transformations import fix_rigid, quaternion_matrix, rotation_matrix +from ..typed import Sequence, Union # we compare to identity a lot _identity = np.eye(4) diff --git a/trimesh/typed.py b/trimesh/typed.py index 63d91abd3..e8e6bc180 100644 --- a/trimesh/typed.py +++ b/trimesh/typed.py @@ -1,29 +1,44 @@ +from io import BytesIO, StringIO from pathlib import Path +from sys import version_info from typing import ( IO, Any, BinaryIO, - Dict, - Iterable, - List, Optional, - Sequence, - Tuple, + TextIO, Union, ) -# our default integer and floating point types -from numpy import float64, int64 +from numpy import float64, floating, int64, integer, unsignedinteger -try: - from numpy.typing import ArrayLike, NDArray -except BaseException: - NDArray = Sequence - ArrayLike = Sequence +# requires numpy>=1.20 +from numpy.typing import ArrayLike, NDArray + +if version_info >= (3, 9): + # use PEP585 hints on newer python + List = list + Tuple = tuple + Dict = dict + from collections.abc import Iterable, Sequence +else: + from typing import Dict, Iterable, List, Sequence, Tuple # most loader routes take `file_obj` which can either be -# a file-like object or a file path -Loadable = Union[str, Path, IO] +# a file-like object or a file path, or sometimes a dict + +Stream = Union[IO, BytesIO, StringIO, BinaryIO, TextIO] +Loadable = Union[str, Path, Stream, Dict, None] + +# numpy integers do not inherit from python integers, i.e. +# if you type a function argument as an `int` and then pass +# a value from a numpy array like `np.ones(10, dtype=np.int64)[0]` +# you may have a type error. +# these wrappers union numpy integers and python integers +Integer = Union[int, integer, unsignedinteger] + +# Many arguments take "any valid number." +Number = Union[float, floating, Integer] __all__ = [ "NDArray", @@ -40,4 +55,7 @@ "Tuple", "float64", "int64", + "Number", + "Integer", + "Stream", ] diff --git a/trimesh/util.py b/trimesh/util.py index aeeb5ee3c..1f3a5331a 100644 --- a/trimesh/util.py +++ b/trimesh/util.py @@ -1444,16 +1444,24 @@ def concatenate(a, b=None): flat.extend(b) else: flat.append(b) + dump = [] + for i in flat: + if is_instance_named(i, "Scene"): + dump.extend(i.dump()) + else: + dump.append(i) - if len(flat) == 1: + if len(dump) == 1: # if there is only one mesh just return the first - return flat[0].copy() - elif len(flat) == 0: - # if there are no meshes return an empty list - return [] + return dump[0].copy() + elif len(dump) == 0: + # if there are no meshes return an empty mesh + from .base import Trimesh + + return Trimesh() - is_mesh = [f for f in flat if is_instance_named(f, "Trimesh")] - is_path = [f for f in flat if is_instance_named(f, "Path")] + is_mesh = [f for f in dump if is_instance_named(f, "Trimesh")] + is_path = [f for f in dump if is_instance_named(f, "Path")] if len(is_path) > len(is_mesh): from .path.util import concatenate as concatenate_path @@ -2107,7 +2115,7 @@ def write_encoded(file_obj, stuff, encoding="utf-8"): def unique_id(length=12): """ - Generate a random alphanumeric unique identifier + Generate a random alphaNumber unique identifier using UUID logic. Parameters @@ -2118,7 +2126,7 @@ def unique_id(length=12): Returns ------------ unique : str - Unique alphanumeric identifier + Unique alphaNumber identifier """ return uuid.UUID(int=random.getrandbits(128), version=4).hex[:length] diff --git a/trimesh/visual/base.py b/trimesh/visual/base.py index 5a04d702a..5810a6734 100644 --- a/trimesh/visual/base.py +++ b/trimesh/visual/base.py @@ -15,7 +15,8 @@ class Visuals(ABC): Parent of Visual classes. """ - @abc.abstractproperty + @property + @abc.abstractmethod def kind(self): pass diff --git a/trimesh/visual/gloss.py b/trimesh/visual/gloss.py index 5d3bdf30e..08ab324dc 100644 --- a/trimesh/visual/gloss.py +++ b/trimesh/visual/gloss.py @@ -2,7 +2,7 @@ from ..constants import log from ..exceptions import ExceptionWrapper -from ..typed import NDArray, Optional, float64 +from ..typed import ArrayLike, Number, Optional try: from PIL.Image import Image, fromarray @@ -12,11 +12,11 @@ def specular_to_pbr( - specularFactor: Optional[NDArray[float64]] = None, - glossinessFactor: Optional[float] = None, + specularFactor: Optional[ArrayLike] = None, + glossinessFactor: Optional[Number] = None, specularGlossinessTexture: Optional["Image"] = None, diffuseTexture: Optional["Image"] = None, - diffuseFactor: float = None, + diffuseFactor: Optional[ArrayLike] = None, **kwargs, ) -> dict: """ diff --git a/trimesh/visual/material.py b/trimesh/visual/material.py index 913fc9a15..09c137509 100644 --- a/trimesh/visual/material.py +++ b/trimesh/visual/material.py @@ -32,7 +32,8 @@ def __init__(self, *args, **kwargs): def __hash__(self): raise NotImplementedError("must be subclassed!") - @abc.abstractproperty + @property + @abc.abstractmethod def main_color(self): """ The "average" color of this material. @@ -726,7 +727,7 @@ def empty_material(color: Optional[NDArray[np.uint8]] = None) -> SimpleMaterial: return SimpleMaterial(image=color_image(color=color)) -def color_image(color: Optional[NDArray[np.uint8]] = None) -> "Image": +def color_image(color: Optional[NDArray[np.uint8]] = None): """ Generate an image with one color. diff --git a/trimesh/voxel/encoding.py b/trimesh/voxel/encoding.py index 83486f575..02e669006 100644 --- a/trimesh/voxel/encoding.py +++ b/trimesh/voxel/encoding.py @@ -39,31 +39,38 @@ def __init__(self, data): self._data = data self._cache = caching.Cache(id_function=self._data.__hash__) - @abc.abstractproperty + @property + @abc.abstractmethod def dtype(self): pass - @abc.abstractproperty + @property + @abc.abstractmethod def shape(self): pass - @abc.abstractproperty + @property + @abc.abstractmethod def sum(self): pass - @abc.abstractproperty + @property + @abc.abstractmethod def size(self): pass - @abc.abstractproperty + @property + @abc.abstractmethod def sparse_indices(self): pass - @abc.abstractproperty + @property + @abc.abstractmethod def sparse_values(self): pass - @abc.abstractproperty + @property + @abc.abstractmethod def dense(self): pass diff --git a/trimesh/voxel/transforms.py b/trimesh/voxel/transforms.py index 0cf7969f5..f611cc7f3 100644 --- a/trimesh/voxel/transforms.py +++ b/trimesh/voxel/transforms.py @@ -1,9 +1,8 @@ -from typing import Optional - import numpy as np from .. import caching, util from .. import transformations as tr +from ..typed import Optional class Transform: