diff --git a/.travis.yml b/.travis.yml index bd8ee237d..3bc267665 100644 --- a/.travis.yml +++ b/.travis.yml @@ -71,7 +71,6 @@ install: - source activate test-environment # make sure we parked our car in the right spot - python -c "import os,sys;assert '{}.{}'.format(*sys.version_info[:2])==os.environ['PYTHON_VERSION']" - # only install the basic version of the library to make sure # trimesh imports and functions with only the minimal install - pip install . @@ -103,6 +102,8 @@ script: - if [[ "$PYTHON_VERSION" == "3.6" ]]; then pip install python-fcl; fi; # lxml stopped supporting EOL Python - if [[ "$PYTHON_VERSION" == "3.4" ]]; then pip install lxml==4.3.5; fi; +# sdist for released shapely has issues on 3.8 +- if [[ "$PYTHON_VERSION" == "3.8" ]]; then pip install shapely==1.7a2; fi; # install most deps here - pip install -q .[easy] diff --git a/docker/builds/apt.bash b/docker/builds/apt.bash index 64dc2d97d..df4f0c06b 100644 --- a/docker/builds/apt.bash +++ b/docker/builds/apt.bash @@ -2,7 +2,7 @@ set -xe apt-get update -qq apt-get upgrade -y -qq --no-install-recommends apt-get install -y -qq --no-install-recommends wget bzip2 supervisor \ - libgl1-mesa-glx libgl1-mesa-dri xvfb xauth libgeos-dev libspatialindex-c5 \ + libgl1-mesa-glx libgl1-mesa-dri xvfb xauth \ libassimp-dev ca-certificates zstd unzip \ freeglut3-dev diff --git a/docker/builds/conda.bash b/docker/builds/conda.bash index a30af25da..a584b6cff 100644 --- a/docker/builds/conda.bash +++ b/docker/builds/conda.bash @@ -19,7 +19,7 @@ conda config --add channels conda-forge # pyembree is used for fast ray tests # this will also install numpy from conda # conda/numpy is compiled with intel's MKL -conda install pyembree +conda install pyembree rtree # install trimesh from the repo cd /tmp/trimesh diff --git a/tests/test_export.py b/tests/test_export.py index 1a6a58d6d..2b3909b90 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -16,7 +16,7 @@ def test_export(self): for mesh in meshes: # disregard texture - mesh.merge_vertices(textured=False) + mesh.merge_vertices(merge_tex=True, merge_norm=True) for file_type in export_types: # skip pointcloud format if file_type in ['xyz', 'gltf']: @@ -50,7 +50,10 @@ def test_export(self): file_obj = export loaded = g.trimesh.load(file_obj=file_obj, - file_type=file_type) + file_type=file_type, + process=True, + merge_norm=True, + merge_tex=True) # if we exported as GLTF/dae it will come back as a Scene if isinstance(loaded, g.trimesh.Scene) and isinstance( diff --git a/tests/test_gltf.py b/tests/test_gltf.py index 1a3c78b55..be108e449 100644 --- a/tests/test_gltf.py +++ b/tests/test_gltf.py @@ -28,7 +28,7 @@ def test_duck(self): g.scene_equal(scene, reloaded) # if we merge ugly it should now be watertight - geom.merge_vertices(textured=False) + geom.merge_vertices(merge_tex=True) assert geom.is_volume def test_tex_export(self): @@ -217,6 +217,30 @@ def test_node_name(self): assert (set(s.graph.nodes_geometry) == set(r.graph.nodes_geometry)) + def test_schema(self): + # get a copy of the GLTF schema and do simple checks + s = g.trimesh.exchange.gltf.get_schema() + + # make sure it has at least the keys we expect + assert set(s['properties'].keys()).issuperset( + {'accessors', + 'animations', + 'asset', + 'buffers', + 'bufferViews', + 'cameras', + 'images', + 'materials', + 'meshes', + 'nodes', + 'samplers', + 'scene', + 'scenes', + 'skins', + 'textures', + 'extensions', + 'extras'}) + if __name__ == '__main__': g.trimesh.util.attach_to_log() diff --git a/tests/test_normals.py b/tests/test_normals.py index 8d70fbbe5..45af8d455 100644 --- a/tests/test_normals.py +++ b/tests/test_normals.py @@ -159,7 +159,7 @@ def test_merge(self): assert g.np.isclose(m.volume, 8.0, atol=1e-4) # without considering normals should just be cube - m.merge_vertices(use_norm=False) + m.merge_vertices(merge_norm=True) assert m.vertices.shape == (8, 3) assert m.faces.shape == (12, 3) assert g.np.isclose(m.volume, 8.0, atol=1e-4) diff --git a/tests/test_paths.py b/tests/test_paths.py index 7b33cdf02..8ff8221d5 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -7,15 +7,6 @@ class VectorTests(g.unittest.TestCase): def test_discrete(self): - try: - # TODO : REMOVE THIS WHEN SHAPELY WORKS IN 3.8 - from shapely import vectorized # noqa - vec_ok = True - except BaseException: - vec_ok = False - g.log.error( - 'no shapely.vectorized!', exc_info=True) - for d in g.get_2D(): # store md5 before requesting passive functions md5 = d.md5() @@ -108,7 +99,7 @@ def test_discrete(self): # Y should not have moved assert g.np.allclose(d.bounds[:, 1], ori[:, 1]) - if vec_ok and len(d.polygons_full) > 0 and len(d.vertices) < 150: + if len(d.polygons_full) > 0 and len(d.vertices) < 150: g.log.info('Checking medial axis on %s', d.metadata['file_name']) m = d.medial_axis() @@ -239,13 +230,6 @@ def test_sample(self): """ Test random sampling of polygons """ - try: - # TODO : REMOVE THIS - from shapely import vectorized # noqa - except BaseException: - g.log.error( - 'no shapely.vectorized!', exc_info=True) - return p = g.Point([0, 0]).buffer(1.0) count = 100 diff --git a/trimesh/base.py b/trimesh/base.py index 1bbf8a11c..f4ebc57ca 100644 --- a/trimesh/base.py +++ b/trimesh/base.py @@ -194,18 +194,17 @@ def __init__(self, # process will remove NaN and Inf values and merge vertices # if validate, will remove degenerate and duplicate faces if process or validate: - self.process() + self.process(validate=validate, **kwargs) # save reference to kwargs self._kwargs = kwargs - def process(self): + def process(self, **kwargs): """ Do the bare minimum processing to make a mesh useful. Does this by: 1) removing NaN and Inf values - 2) merging duplicate vertices If self._validate: @@ -226,10 +225,10 @@ def process(self): # avoid clearing the cache during operations with self._cache: self.remove_infinite_values() - self.merge_vertices() + self.merge_vertices(**kwargs) # if we're cleaning remove duplicate # and degenerate faces - if self._validate: + if self._validate or ('validate' in kwargs and kwargs['validate']): self.remove_duplicate_faces() self.remove_degenerate_faces() # since none of our process operations moved vertices or faces @@ -1101,9 +1100,9 @@ def merge_vertices(self, **kwargs): Number of digits to consider for UV coordinates """ if 'textured' in kwargs: - kwargs['use_tex'] = kwargs.pop('textured') + kwargs['merge_tex'] = not kwargs.pop('textured') log.warning( - 'merge_vertices depreciation: `textured`->`use_tex`') + 'merge_vertices depreciation: `not textured`->`merge_tex`') grouping.merge_vertices(self, **kwargs) def update_vertices(self, mask, inverse=None): @@ -1557,7 +1556,7 @@ def is_volume(self): self.volume > 0.0) return valid - @caching.cache_decorator + @property def is_empty(self): """ Does the current mesh have data defined. @@ -1918,7 +1917,7 @@ def subdivide(self, face_index=None): return result @log_time - def smoothed(self, angle=None, facet_minlen=4): + def smoothed(self, **kwargs): """ Return a version of the current mesh which will render nicely, without changing source mesh. @@ -1928,8 +1927,10 @@ def smoothed(self, angle=None, facet_minlen=4): angle : float or None Angle in radians face pairs with angles smaller than this will appear smoothed - facet_minlen : int or None - Minimum length of facets to consider + facet_minarea : float or None + Minimum area fraction to consider + IE for `facets_minarea=25` only facets larger + than `mesh.area / 25` will be considered. Returns --------- @@ -1945,9 +1946,7 @@ def smoothed(self, angle=None, facet_minlen=4): return cached # run smoothing smoothed = graph.smoothed( - self, - angle=angle, - facet_minlen=facet_minlen) + self, **kwargs) self.visual._cache['smoothed'] = smoothed return smoothed diff --git a/trimesh/collision.py b/trimesh/collision.py index 8b30cf041..fd0968eef 100644 --- a/trimesh/collision.py +++ b/trimesh/collision.py @@ -242,8 +242,11 @@ def set_transform(self, name, transform): else: raise ValueError('{} not in collision manager!'.format(name)) - def in_collision_single(self, mesh, transform=None, - return_names=False, return_data=False): + def in_collision_single(self, + mesh, + transform=None, + return_names=False, + return_data=False): """ Check a single object for collisions against all objects in the manager. @@ -265,10 +268,10 @@ def in_collision_single(self, mesh, transform=None, is_collision : bool True if a collision occurs and False otherwise names : set of str - The set of names of objects that collided with the + [OPTIONAL] The set of names of objects that collided with the provided one contacts : list of ContactData - All contacts detected + [OPTIONAL] All contacts detected """ if transform is None: transform = np.eye(4) diff --git a/trimesh/exchange/gltf.py b/trimesh/exchange/gltf.py index 5c72edcdb..dea7601e5 100644 --- a/trimesh/exchange/gltf.py +++ b/trimesh/exchange/gltf.py @@ -1237,7 +1237,7 @@ def get_schema(): A copy of the GLTF 2.0 schema without external references. """ # replace references - from ..schemas import resolve_json + from ..schemas import resolve # get zip resolver to access referenced assets from ..visual.resolvers import ZipResolver @@ -1247,11 +1247,13 @@ def get_schema(): archive = util.decompress(util.wrap_as_stream(blob), 'zip') # get a resolver object for accessing the schema resolver = ZipResolver(archive) - # remove references to other files in the schema and load - schema = json.loads( - resolve_json( - resolver.get('glTF.schema.json').decode('utf-8'), - resolver=resolver)) + # get a loaded dict from the base file + unresolved = json.loads(util.decode_text( + resolver.get('glTF.schema.json'))) + # remove references to other files in the schema + schema = resolve(unresolved, + resolver=resolver) + return schema diff --git a/trimesh/graph.py b/trimesh/graph.py index f82b408ed..1f2e65ab7 100644 --- a/trimesh/graph.py +++ b/trimesh/graph.py @@ -760,7 +760,7 @@ def edges_to_coo(edges, count=None, data=None): return matrix -def smoothed(mesh, angle, facet_minlen=4): +def smoothed(mesh, angle=None, facet_minarea=15): """ Return a non- watertight version of the mesh which will render nicely with smooth shading by @@ -770,12 +770,13 @@ def smoothed(mesh, angle, facet_minlen=4): ----------- mesh : trimesh.Trimesh Source geometry - angle : float - Angle in radians: adjacent faces - below this angle will be smoothed - facet_minlen : None or int - If specified will specially group facets - with more faces + angle : float or None + Angle in radians face pairs with angles + smaller than this will appear smoothed + facet_minarea : float or None + Minimum area fraction to consider + IE for `facets_minarea=25` only facets larger + than `mesh.area / 25` will be considered. Returns --------- @@ -798,12 +799,14 @@ def smoothed(mesh, angle, facet_minlen=4): facets = [] nodes = None # collect coplanar regions for smoothing - if facet_minlen is not None: + if facet_minarea is not None: + areas = mesh.area_faces + min_area = mesh.area / facet_minarea try: # we can survive not knowing facets # exclude facets with few faces facets = [f for f in mesh.facets - if len(f) > facet_minlen] + if areas[f].sum() > min_area] if len(facets) > 0: # mask for removing adjacency pairs where # one of the faces is contained in a facet diff --git a/trimesh/grouping.py b/trimesh/grouping.py index dff28ae44..bc77ca06c 100644 --- a/trimesh/grouping.py +++ b/trimesh/grouping.py @@ -21,25 +21,26 @@ def merge_vertices(mesh, - use_tex=True, - use_norm=True, + merge_tex=False, + merge_norm=False, digits_vertex=None, digits_norm=2, - digits_uv=4): + digits_uv=4, + **kwargs): """ - Removes duplicate vertices based on integer hashes of + Removes duplicate vertices. By default, based on integer hashes of each row. Parameters ------------- mesh : Trimesh object Mesh to merge vertices on - use_tex : bool - If True for textured meshes merge vertices - with identical positions AND UV coordinates. - use_norm : bool - If True meshes with vertex normals defined will - only have vertices merged with identical normal + merge_tex : bool + If True textured meshes with UV coordinates will + have vertices merged regardless of UV coordinates + merge_norm : bool + If True, meshes with vertex normals will have + vertices merged ignoring different normals digits_vertex : None or int Number of digits to consider for vertex position digits_norm : int @@ -60,25 +61,26 @@ def merge_vertices(mesh, # this is used for geometry without faces referenced = np.ones(len(mesh.vertices), dtype=np.bool) - # collect vertex attributes into sequence + # collect vertex attributes into sequence we can stack stacked = [mesh.vertices * (10 ** digits_vertex)] # UV texture visuals require us to update the # vertices and normals differently - if (use_tex and + if (not merge_tex and mesh.visual.defined and mesh.visual.kind == 'texture' and - mesh.visual.uv is not None): + mesh.visual.uv is not None and + len(mesh.visual.uv) == len(mesh.vertices)): # get an array with vertices and UV coordinates # converted to integers at requested precision stacked.append(mesh.visual.uv * (10 ** digits_uv)) # check to see if we have vertex normals normals = mesh._cache['vertex_normals'] - if use_norm and np.shape(normals) == np.shape(mesh.vertices): + if not merge_norm and np.shape(normals) == mesh.vertices.shape: stacked.append(normals * (10 ** digits_norm)) - # stack collected vertex properties and round to int + # stack collected vertex properties and round to integer stacked = np.column_stack(stacked).round().astype(np.int64) # check unique rows of referenced vertices diff --git a/trimesh/path/segments.py b/trimesh/path/segments.py index d7c36e73b..c3c35eea0 100644 --- a/trimesh/path/segments.py +++ b/trimesh/path/segments.py @@ -11,6 +11,7 @@ from .. import grouping from .. import geometry from .. import interval +from .. import transformations from ..constants import tol @@ -249,7 +250,7 @@ def unique(segments, digits=5): inverse.sort(axis=1) # remove segments where both indexes are the same mask = np.zeros(len(segments), dtype=np.bool) - # only include the first occurance of a segment + # only include the first occurrence of a segment mask[grouping.unique_rows(inverse)[0]] = True # remove segments that are zero-length mask[inverse[:, 0] == inverse[:, 1]] = False @@ -509,7 +510,7 @@ def resample(segments, return result -def to_svg(segments, digits=4, scale=None, origin=None): +def to_svg(segments, digits=4, matrix=None): """ Convert (n, 2, 2) line segments to an SVG path string. @@ -519,10 +520,8 @@ def to_svg(segments, digits=4, scale=None, origin=None): Line segments to convert digits : int Number of digits to include in SVG string - scale : None or float - Scale to apply before exporting - origin : None or (2,) float - Origin to translate before scaling + matrix : None or (3, 3) float + Homogeneous 2D transformation to apply before export Returns ----------- @@ -530,19 +529,17 @@ def to_svg(segments, digits=4, scale=None, origin=None): SVG path string with one line per segment IE: 'M 0.1 0.2 L 10 12' """ - segments = np.asanyarray(segments) + segments = np.array(segments, copy=True) if not util.is_shape(segments, (-1, 2, 2)): raise ValueError('only for (n, 2, 2) segments!') # create the array to export - flat = segments.copy().reshape((-1, 2)) - # apply origin if passed - if origin is not None: - flat -= np.array(origin).reshape(2) - # then apply scale if passed - if scale is not None: - flat *= float(scale) - # create the base format for a single line segment + flat = segments.reshape((-1, 2)) + # apply 2D transformation if passed + if matrix is not None: + flat = transformations.transform_points( + flat, matrix=matrix) + # create the format string for a single line segment base = ' M _ _ L _ _'.replace( '_', '{:0.' + str(int(digits)) + 'f}') # create one large format string then apply points diff --git a/trimesh/resources/gltf_2_schema.zip b/trimesh/resources/gltf_2_schema.zip index dc64d63e8..77ca496d2 100644 Binary files a/trimesh/resources/gltf_2_schema.zip and b/trimesh/resources/gltf_2_schema.zip differ diff --git a/trimesh/schemas.py b/trimesh/schemas.py index 932a756a3..d3180e1a5 100644 --- a/trimesh/schemas.py +++ b/trimesh/schemas.py @@ -6,78 +6,40 @@ """ import json +from .util import decode_text -def resolve_json(text, resolver, recursive=True, fill_empty='{}'): + +def resolve(item, resolver): """ - Given a JSON Schema containing `$ref` keys, replace all - referenced URI values with their values using trimesh - Resolver objects. + Given a JSON Schema containing `$ref` keys recursively + evaluate to find and replace referenced files with their + actual values using trimesh.visual.Resolver objects. Parameters --------------- - text : str - JSON text including `$ref` to other files + item : any + JSON schema including `$ref` to other files resolver : trimesh.visual.resolver.Resolver Resolver to fetch referenced assets - recursive : bool - If True, resolve references in referenced files - fill_empty : str - What to replace empty references with Returns ---------- - result : str - JSON text with references filled in + result : any + JSONSchema with references replaced """ - if '$ref' not in text: - return text - # append strings to list then concatenate once - result = [] - # set the current value to the input text - current = text - # loop with a for to cap iterations - for i in range(len(text)): - # find the referenced key in the text - idx = current.find('$ref') - # if the string wasn't found find will return -1 - if idx < 0: - break - # find the first opening bracket before the ref key - first = current.rfind('{', 0, idx) - # find the first closing bracket after the ref key - last = current.find('}', idx) - - # extract the URI of the reference - name = json.loads(current[first:last + 1])['$ref'] - - # get the bytes from the resolver - data = resolver.archive[name].read().decode('utf-8') - - # include all text before the first reference - result.append(current[:first]) - - if recursive: - result.append( - resolve_json( - data, resolver=resolver, recursive=True)) + if isinstance(item, list): + # run the resolver on every list item + return [resolve(i, resolver) for i in item] + elif isinstance(item, dict): + if '$ref' in item: + # if we have a reference to a file pop the key + # and update the dict with the reference in-place + raw = decode_text(resolver.get(item.pop('$ref'))) + item.update(json.loads(raw)) + # run the resolver on the dict again + resolve(item, resolver) else: - # include the loaded data - result.append(data) - - # if we had a reference to an empty file fill it - if len(result[-1].strip()) == 0: - result.append(fill_empty) - - # for the next loop only look at the next chunk - current = current[last + 1:] - - # any values after the last reference - result.append(current) - - # append results into single string - appended = ' '.join(result) - - # assert we got rid of all the references - assert '$ref' not in appended - - return appended + # make sure all keys are evaluated + for i in item.values(): + resolve(i, resolver) + return item diff --git a/trimesh/version.py b/trimesh/version.py index aa89269e5..89b1ca69d 100644 --- a/trimesh/version.py +++ b/trimesh/version.py @@ -1 +1 @@ -__version__ = '3.5.7' +__version__ = '3.5.9' diff --git a/trimesh/viewer/windowed.py b/trimesh/viewer/windowed.py index 4b54dbb32..9fca737a0 100644 --- a/trimesh/viewer/windowed.py +++ b/trimesh/viewer/windowed.py @@ -24,7 +24,6 @@ pyglet.options['shadow_window'] = False - # smooth only when fewer faces than this _SMOOTH_MAX_FACES = 100000 @@ -45,6 +44,7 @@ def __init__(self, offset_lines=True, background=None, window_conf=None, + profile=False, ** kwargs): """ Create a window that will display a trimesh.Scene object @@ -103,6 +103,11 @@ def __init__(self, self.batch = pyglet.graphics.Batch() self._smooth = smooth + self._profile = bool(profile) + if self._profile: + from pyinstrument import Profiler + self.Profiler = Profiler + # store kwargs self.kwargs = kwargs @@ -608,6 +613,10 @@ def on_draw(self): Run the actual draw calls. """ + if self._profile: + profiler = self.Profiler() + profiler.start() + self._update_meshes() gl.glClear(gl.GL_COLOR_BUFFER_BIT | gl.GL_DEPTH_BUFFER_BIT) gl.glLoadIdentity() @@ -707,6 +716,10 @@ def on_draw(self): if texture is not None: gl.glDisable(texture.target) + if self._profile: + profiler.stop() + print(profiler.output_text(unicode=True, color=True)) + def save_image(self, file_obj): """ Save the current color buffer to a file object diff --git a/trimesh/visual/resolvers.py b/trimesh/visual/resolvers.py index 465049c34..21c2db55e 100644 --- a/trimesh/visual/resolvers.py +++ b/trimesh/visual/resolvers.py @@ -125,7 +125,9 @@ def get(self, name): # read file object from beginning obj.seek(0) # data is stored as a file object - return obj.read() + data = obj.read() + obj.seek(0) + return data class WebResolver(Resolver):