Repository: mikedh/trimesh

Issue:
Cache incorrect for `scene.graph.nodes` after using `scene.graph.transforms.remove_node`
Hi!
After using `scene.graph.transforms.remove_node(..)`, the value of `scene.graph.nodes` is incorrect / not updated. I assume this is due to the caching mechanism. How can I explicitly invalidate the cache such that the function gets the most recent value from `self.transforms.nodes`?

Thanks!

Discussion:

Patch:
diff --git a/README.md b/README.md
index 9acbcbad..c86a292c 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@ Keeping `trimesh` easy to install is a core goal, thus the *only* hard dependenc
 pip install trimesh
 ```
 
-More functionality is available when soft dependencies are installed. This includes things like convex hulls (`scipy`), graph operations (`networkx`), faster ray queries (`pyembree`), vector path handling (`shapely` and `rtree`), preview windows (`pyglet`), faster cache checks (`xxhash`), etc. To install `trimesh` with the soft dependencies that generally install cleanly on Linux, OSX, and Windows using `pip`:
+The minimal install can load many supported formats (STL, PLY, GLTF/GLB) into numpy arrays. More functionality is available when soft dependencies are installed. This includes things like convex hulls (`scipy`), graph operations (`networkx`), faster ray queries (`pyembree`), vector path handling (`shapely` and `rtree`), XML formats like 3DXML/XAML/3MF (`lxml`), preview windows (`pyglet`), faster cache checks (`xxhash`), etc. To install `trimesh` with the soft dependencies that generally install cleanly on Linux, OSX, and Windows using `pip`:
 ```bash
 pip install trimesh[easy]
 ```
diff --git a/trimesh/base.py b/trimesh/base.py
index 220d01ea..d1254612 100644
--- a/trimesh/base.py
+++ b/trimesh/base.py
@@ -2502,6 +2502,42 @@ class Trimesh(Geometry3D):
         return Path3D(**faces_to_path(
             self, face_ids, **kwargs))
 
+    def projected(self,
+                  normal,
+                  **kwargs):
+        """
+        Project a mesh onto a plane and then extract the polygon
+        that outlines the mesh projection on that plane.
+
+        Parameters
+        ----------
+        mesh : trimesh.Trimesh
+          Source geometry
+        check : bool
+          If True make sure is flat
+        normal : (3,) float
+          Normal to extract flat pattern along
+        origin : None or (3,) float
+          Origin of plane to project mesh onto
+        pad : float
+          Proportion to pad polygons by before unioning
+          and then de-padding result by to avoid zero-width gaps.
+        tol_dot : float
+          Tolerance for discarding on-edge triangles.
+        max_regions : int
+          Raise an exception if the mesh has more than this
+          number of disconnected regions to fail quickly before unioning.
+
+        Returns
+        ----------
+        projected : trimesh.path.Path2D
+          Outline of source mesh
+        """
+        from .exchange.load import load_path
+        from .path.polygons import projected
+
+        return load_path(projected(mesh=self, normal=normal, **kwargs))
+
     @caching.cache_decorator
     def area(self):
         """
@@ -2525,8 +2561,9 @@ class Trimesh(Geometry3D):
         area_faces : (n, ) float
           Area of each face
         """
-        area_faces = triangles.area(crosses=self.triangles_cross,
-                                    sum=False)
+        area_faces = triangles.area(
+            crosses=self.triangles_cross,
+            sum=False)
         return area_faces
 
     @caching.cache_decorator
diff --git a/trimesh/caching.py b/trimesh/caching.py
index ea7b68d3..fb177b7b 100644
--- a/trimesh/caching.py
+++ b/trimesh/caching.py
@@ -543,9 +543,7 @@ class DataStore(Mapping):
         is_mutable : bool
           Can data be altered in the DataStore
         """
-        if not hasattr(self, '_mutable'):
-            return True
-        return self._mutable
+        return getattr(self, '_mutable', True)
 
     @mutable.setter
     def mutable(self, value):
diff --git a/trimesh/exchange/threedxml.py b/trimesh/exchange/threedxml.py
index 6d7b4808..d8113f19 100644
--- a/trimesh/exchange/threedxml.py
+++ b/trimesh/exchange/threedxml.py
@@ -57,50 +57,54 @@ def load_3DXML(file_obj, *args, **kwargs):
 
     # load the materials library from the materials elements
     colors = {}
-    material_tree = as_etree['CATMaterialRef.3dxml']
-    for MaterialDomain in material_tree.iter('{*}MaterialDomain'):
-        material_id = MaterialDomain.attrib['id']
-        material_file = MaterialDomain.attrib['associatedFile'].split(
-            'urn:3DXML:')[-1]
-        rend = as_etree[material_file].find(
-            "{*}Feature[@Alias='RenderingFeature']")
-        diffuse = rend.find("{*}Attr[@Name='DiffuseColor']")
-        # specular = rend.find("{*}Attr[@Name='SpecularColor']")
-        # emissive = rend.find("{*}Attr[@Name='EmissiveColor']")
-        rgb = (np.array(json.loads(
-            diffuse.attrib['Value'])) * 255).astype(np.uint8)
-        colors[material_id] = rgb
-
-    # copy indexes for instances of colors
-    for MaterialDomainInstance in material_tree.iter(
-            '{*}MaterialDomainInstance'):
-        instance = MaterialDomainInstance.find('{*}IsInstanceOf')
-        # colors[b.attrib['id']] = colors[instance.text]
-        for aggregate in MaterialDomainInstance.findall('{*}IsAggregatedBy'):
-            colors[aggregate.text] = colors[instance.text]
+    # but only if it exists
+    material_key = 'CATMaterialRef.3dxml'
+    if material_key in as_etree:
+        material_tree = as_etree[material_key]
+        for MaterialDomain in material_tree.iter('{*}MaterialDomain'):
+            material_id = MaterialDomain.attrib['id']
+            material_file = MaterialDomain.attrib['associatedFile'].split(
+                'urn:3DXML:')[-1]
+            rend = as_etree[material_file].find(
+                "{*}Feature[@Alias='RenderingFeature']")
+            diffuse = rend.find("{*}Attr[@Name='DiffuseColor']")
+            # specular = rend.find("{*}Attr[@Name='SpecularColor']")
+            # emissive = rend.find("{*}Attr[@Name='EmissiveColor']")
+            rgb = (np.array(json.loads(
+                diffuse.attrib['Value'])) * 255).astype(np.uint8)
+            colors[material_id] = rgb
+
+        # copy indexes for instances of colors
+        for MaterialDomainInstance in material_tree.iter(
+                '{*}MaterialDomainInstance'):
+            instance = MaterialDomainInstance.find('{*}IsInstanceOf')
+            # colors[b.attrib['id']] = colors[instance.text]
+            for aggregate in MaterialDomainInstance.findall('{*}IsAggregatedBy'):
+                colors[aggregate.text] = colors[instance.text]
 
     # references which hold the 3DXML scene structure as a dict
     # element id : {key : value}
     references = collections.defaultdict(dict)
 
-    # the 3DXML can specify different visual properties for  occurrences
+    # the 3DXML can specify different visual properties for occurrences
     view = tree.find('{*}DefaultView')
-    for ViewProp in view.iter('{*}DefaultViewProperty'):
-        color = ViewProp.find('{*}GraphicProperties/' +
-                              '{*}SurfaceAttributes/{*}Color')
-        if (color is None or
-                'RGBAColorType' not in color.attrib.values()):
-            continue
-        rgba = np.array([color.attrib[i]
-                         for i in ['red',
-                                   'green',
-                                   'blue',
-                                   'alpha']],
-                        dtype=np.float64)
-        rgba = (rgba * 255).astype(np.uint8)
-        for occurrence in ViewProp.findall('{*}OccurenceId/{*}id'):
-            reference_id = occurrence.text.split('#')[-1]
-            references[reference_id]['color'] = rgba
+    if view is not None:
+        for ViewProp in view.iter('{*}DefaultViewProperty'):
+            color = ViewProp.find('{*}GraphicProperties/' +
+                                  '{*}SurfaceAttributes/{*}Color')
+            if (color is None or
+                    'RGBAColorType' not in color.attrib.values()):
+                continue
+            rgba = np.array([color.attrib[i]
+                             for i in ['red',
+                                       'green',
+                                       'blue',
+                                       'alpha']],
+                            dtype=np.float64)
+            rgba = (rgba * 255).astype(np.uint8)
+            for occurrence in ViewProp.findall('{*}OccurenceId/{*}id'):
+                reference_id = occurrence.text.split('#')[-1]
+                references[reference_id]['color'] = rgba
 
     # geometries will hold meshes
     geometries = dict()
@@ -118,6 +122,12 @@ def load_3DXML(file_obj, *args, **kwargs):
         mesh_normals = []
         mesh_vertices = []
 
+        if part_file not in as_etree and part_file in archive:
+            # the data is stored in some binary format
+            # blob = archive[part_file].read()
+            util.log.warning('unable to load binary Rep')
+            continue
+
         # the geometry is stored in a Rep
         for Rep in as_etree[part_file].iter('{*}Rep'):
             faces = Rep.find('{*}Faces/{*}Face')
@@ -296,7 +306,7 @@ def load_3DXML(file_obj, *args, **kwargs):
 
 def print_element(element):
     """
-    Pretty- print an lxml.etree element.
+    Pretty-print an lxml.etree element.
 
     Parameters
     ------------
diff --git a/trimesh/path/entities.py b/trimesh/path/entities.py
index 31350008..f7be1d4e 100644
--- a/trimesh/path/entities.py
+++ b/trimesh/path/entities.py
@@ -585,9 +585,7 @@ class Arc(Entity):
         closed : bool
           If set True, Arc will be a closed circle
         """
-        if hasattr(self, '_closed'):
-            return self._closed
-        return False
+        return getattr(self, '_closed', False)
 
     @closed.setter
     def closed(self, value):
diff --git a/trimesh/path/packing.py b/trimesh/path/packing.py
index 49153954..6c919a73 100644
--- a/trimesh/path/packing.py
+++ b/trimesh/path/packing.py
@@ -217,12 +217,9 @@ def paths(paths, **kwargs):
     """
     from .util import concatenate
 
-    quantity = []
-    for path in paths:
-        if 'quantity' in path.metadata:
-            quantity.append(path.metadata['quantity'])
-        else:
-            quantity.append(1)
+    # default quantity to 1
+    quantity = [i.metadata.get('quantity', 1)
+                for i in paths]
 
     # pack using exterior polygon (will OBB)
     packable = [i.polygons_closed[i.root[0]] for i in paths]
@@ -273,7 +270,8 @@ def polygons(polygons,
     overall_inserted : (m,) int
       Indexes of inserted polygons
     packed : (m, 3, 3) float
-      Homogeonous transforms from original frame to packed frame
+      Homogeonous transforms from original frame to
+      packed frame.
     """
 
     from .polygons import polygons_obb
diff --git a/trimesh/scene/transforms.py b/trimesh/scene/transforms.py
index 5284d28a..e472ef60 100644
--- a/trimesh/scene/transforms.py
+++ b/trimesh/scene/transforms.py
@@ -151,9 +151,8 @@ class SceneGraph(object):
         """
         Return the last time stamp data was modified.
         """
-        if hasattr(self, '_modified'):
-            return self._modified
-        return '0.0'
+        return (getattr(self, '_modified', '-') +
+                getattr(self.transforms, '_modified', '-'))
 
     def copy(self):
         """
@@ -520,6 +519,7 @@ class EnforcedForest(object):
         # topology has changed so clear cache
         if (u, v) not in self.edge_data:
             self._cache = {}
+            self._modified = str(uuid.uuid4())
         else:
             # check to see if matrix and geometry are identical
             edge = self.edge_data[(u, v)]
@@ -563,6 +563,7 @@ class EnforcedForest(object):
 
         # topology will change so clear cache
         self._cache = {}
+        self._modified = str(uuid.uuid4())
 
         # delete all children's references and parent reference
         children = [child for (child, parent) in self.parents.items() if parent == u]
@@ -703,6 +704,12 @@ class EnforcedForest(object):
                 collected.update(childs)
         return collected
 
+    def modified(self):
+        """
+        Return the last time stamp data was modified.
+        """
+        return getattr(self, '_modified', '-')
+
 
 def kwargs_to_matrix(
         matrix=None,
diff --git a/trimesh/version.py b/trimesh/version.py
index 8bf33651..0c4a7494 100644
--- a/trimesh/version.py
+++ b/trimesh/version.py
@@ -1,1 +1,1 @@
-__version__ = '3.9.35'
+__version__ = '3.9.36'
diff --git a/trimesh/visual/base.py b/trimesh/visual/base.py
index 37424c41..bfcbcfe9 100644
--- a/trimesh/visual/base.py
+++ b/trimesh/visual/base.py
@@ -9,11 +9,9 @@ from ..util import ABC
 
 
 class Visuals(ABC):
-
     """
     Parent of Visual classes.
     """
-
     @abc.abstractproperty
     def kind(self):
         pass
diff --git a/trimesh/visual/color.py b/trimesh/visual/color.py
index 6962a108..ee423152 100644
--- a/trimesh/visual/color.py
+++ b/trimesh/visual/color.py
@@ -154,6 +154,7 @@ class ColorVisuals(Visuals):
         """
         Return a copy of the current ColorVisuals object.
 
+
         Returns
         ----------
         copied : ColorVisuals
@@ -412,11 +413,17 @@ class ColorVisuals(Visuals):
         ----------
         visual: ColorVisuals object containing a subset of faces.
         """
+        kwargs = {}
         if self.defined:
-            result = ColorVisuals(
-                face_colors=self.face_colors[face_index])
-        else:
-            result = ColorVisuals()
+            if self.face_colors is not None:
+                kwargs.update(face_colors=self.face_colors[face_index])
+
+            if self.vertex_colors is not None:
+                indices = np.unique(self.mesh.faces[face_index].flatten())
+                vertex_colors = self.vertex_colors[indices]
+                kwargs.update(vertex_colors=vertex_colors)
+
+        result = ColorVisuals(**kwargs)
 
         return result
 
diff --git a/trimesh/visual/material.py b/trimesh/visual/material.py
index f985155f..d6a75dd9 100644
--- a/trimesh/visual/material.py
+++ b/trimesh/visual/material.py
@@ -124,8 +124,7 @@ class SimpleMaterial(Material):
             # what is the name of the export image to save
             if image_type is None:
                 image_type = 'png'
-            image_name = '{}.{}'.format(
-                tex_name, image_type).lower()
+            image_name = '{}.{}'.format(tex_name, image_type.lower())
             # save the reference to the image
             mtl.append('map_Kd {}'.format(image_name))
 
diff --git a/trimesh/visual/texture.py b/trimesh/visual/texture.py
index 448bb90e..f783a578 100644
--- a/trimesh/visual/texture.py
+++ b/trimesh/visual/texture.py
@@ -125,7 +125,7 @@ class TextureVisuals(Visuals):
             self.vertex_attributes['uv'] = np.asanyarray(
                 values, dtype=np.float64)
 
-    def copy(self):
+    def copy(self, uv=None):
         """
         Return a copy of the current TextureVisuals object.
 
@@ -134,7 +134,8 @@ class TextureVisuals(Visuals):
         copied : TextureVisuals
           Contains the same information in a new object
         """
-        uv = self.uv
+        if uv is None:
+            uv = self.uv
         if uv is not None:
             uv = uv.copy()
         copied = TextureVisuals(
@@ -164,7 +165,11 @@ class TextureVisuals(Visuals):
         """
         Get a copy of
         """
-        return self.copy()
+        if self.uv is not None:
+            indices = np.unique(self.mesh.faces[face_index].flatten())
+            return self.copy(self.uv[indices])
+        else:
+            return self.copy()
 
     def update_vertices(self, mask):
         """


Test patch:
diff --git a/tests/test_scenegraph.py b/tests/test_scenegraph.py
index 0ded4854..9d5f8db5 100644
--- a/tests/test_scenegraph.py
+++ b/tests/test_scenegraph.py
@@ -63,6 +63,16 @@ class GraphTests(g.unittest.TestCase):
         assert isinstance(s.graph.to_networkx(),
                           g.nx.DiGraph)
 
+    def test_nodes(self):
+        # get a scene graph
+        graph = g.get_mesh('cycloidal.3DXML').graph
+        # get any non-root node
+        node = list(graph.nodes.difference(['world']))[0]
+        # remove that node
+        graph.transforms.remove_node(node)
+        # should have dumped the cache and removed the node
+        assert node not in graph.nodes
+
     def test_kwargs(self):
         # test the function that converts various
         # arguments into a homogeneous transformation
@@ -98,7 +108,7 @@ class GraphTests(g.unittest.TestCase):
         assert s.graph.transforms.remove_node("1")
 
         assert len(s.graph.nodes_geometry) == 5
-        assert len(s.graph.nodes) == 9
+        assert len(s.graph.nodes) == 8
         assert len(s.graph.transforms.node_data) == 8
         assert len(s.graph.transforms.edge_data) == 6
         assert len(s.graph.transforms.parents) == 6
diff --git a/tests/test_smooth.py b/tests/test_smooth.py
new file mode 100644
index 00000000..c1b83e88
--- /dev/null
+++ b/tests/test_smooth.py
@@ -0,0 +1,25 @@
+try:
+    from . import generic as g
+except BaseException:
+    import generic as g
+
+
+class SmoothTest(g.unittest.TestCase):
+
+    def test_smooth(self):
+        m = g.get_mesh('chair_model.obj', force='mesh')
+        s = m.smoothed()
+
+        ori = g.np.hstack((m.visual.uv, m.vertices))
+        check = g.np.hstack((s.visual.uv, s.vertices))
+
+        tree = g.spatial.cKDTree(ori)
+        distances, index = tree.query(check, k=1)
+        assert distances.max() < 1e-8
+
+        # g.texture_equal(m, s)
+
+
+if __name__ == '__main__':
+    g.trimesh.util.attach_to_log()
+    g.unittest.main()
diff --git a/tests/test_visual.py b/tests/test_visual.py
new file mode 100644
index 00000000..ab771361
--- /dev/null
+++ b/tests/test_visual.py
@@ -0,0 +1,59 @@
+try:
+    from . import generic as g
+except BaseException:
+    import generic as g
+
+
+class VisualTest(g.unittest.TestCase):
+
+    def test_face_subset_texture_visuals(self):
+        m = g.get_mesh('fuze.obj', force='mesh')
+
+        face_index = g.np.random.choice(len(m.faces), len(m.triangles) // 2)
+        idx = m.faces[g.np.unique(face_index)].flatten()
+
+        ori = m.visual.uv[idx]
+        check = m.visual.face_subset(face_index).uv
+
+        tree = g.spatial.cKDTree(ori)
+        distances, index = tree.query(check, k=1)
+        assert distances.max() < 1e-8
+
+    def test_face_subset_color_visuals(self):
+        import trimesh
+        m = g.get_mesh('torus.STL')
+
+        vertex_colors = g.np.random.randint(0, 255, size=(len(m.vertices), 3))
+        m.visual = trimesh.visual.ColorVisuals(mesh=m, vertex_colors=vertex_colors)
+
+        face_index = g.np.random.choice(len(m.faces), len(m.triangles) // 2)
+        idx = m.faces[g.np.unique(face_index)].flatten()
+
+        ori = m.visual.vertex_colors[idx]
+        check = m.visual.face_subset(face_index).vertex_colors
+
+        tree = g.spatial.cKDTree(ori)
+        distances, index = tree.query(check, k=1)
+        assert distances.max() < 1e-8
+
+    # def test_face_subset_vertex_color(self):
+    #     import trimesh
+    #     m = g.get_mesh('torus.STL')
+    #
+    #     vertex_colors = trimesh.visual.VertexColor(mesh=m, vertex_color=)
+    #     m.visual = trimesh.visual.VertexColor(mesh=m, colors=vertex_colors)
+    #
+    #     face_index = g.np.random.choice(len(m.faces), len(m.triangles) // 2)
+    #     idx = m.faces[g.np.unique(face_index)].flatten()
+    #
+    #     ori = m.visual.vertex_colors[idx]
+    #     check = m.visual.face_subset(face_index).vertex_colors
+    #
+    #     tree = g.spatial.cKDTree(ori)
+    #     distances, index = tree.query(check, k=1)
+    #     assert distances.max() < 1e-8
+
+
+if __name__ == '__main__':
+    g.trimesh.util.attach_to_log()
+    g.unittest.main()