Repository: mikedh/trimesh

Issue:
Feature Request: Support color for Path3D
It appears that it isn't possible to currently change the color of a Path3D or it's entities. This would be very helpful as a diagnostic tool.

Discussion:

Patch:
diff --git a/trimesh/parent.py b/trimesh/parent.py
index c7388051..86a75d3f 100644
--- a/trimesh/parent.py
+++ b/trimesh/parent.py
@@ -9,6 +9,7 @@ import abc
 import numpy as np
 
 from . import caching
+from . import transformations
 from .util import ABC
 
 
@@ -83,19 +84,14 @@ class Geometry(ABC):
 
     def apply_scale(self, scaling):
         """
-        Scale the mesh equally on all axis.
+        Scale the mesh.
 
         Parameters
         ----------
-        scaling : float
+        scaling : float or (3,) float
           Scale factor to apply to the mesh
         """
-        scaling = float(scaling)
-        if not np.isfinite(scaling):
-            raise ValueError('Scaling factor must be finite number!')
-
-        matrix = np.eye(4)
-        matrix[:3, :3] *= scaling
+        matrix = transformations.scale_and_translate(scale=scaling)
         # apply_transform will work nicely even on negative scales
         return self.apply_transform(matrix)
 
diff --git a/trimesh/path/entities.py b/trimesh/path/entities.py
index 06666f6e..07e5dc46 100644
--- a/trimesh/path/entities.py
+++ b/trimesh/path/entities.py
@@ -31,6 +31,8 @@ class Entity(object):
             self.closed = closed
         # save the passed layer
         self.layer = layer
+        # save the passed color
+        self.color = color
         # save any other kwargs for general use
         self.kwargs = kwargs
 
diff --git a/trimesh/path/exchange/dxf.py b/trimesh/path/exchange/dxf.py
index 04e20ae7..150c52b3 100644
--- a/trimesh/path/exchange/dxf.py
+++ b/trimesh/path/exchange/dxf.py
@@ -555,16 +555,11 @@ def export_dxf(path, layers=None):
         subs : dict
           Has keys 'COLOR', 'LAYER', 'NAME'
         """
+        # TODO : convert RGBA entity.color to index
         subs = {'COLOR': 255,  # default is ByLayer
                 'LAYER': 0,
                 'NAME': str(id(entity))[:16]}
 
-        if hasattr(entity, 'color'):
-            # all colors must be integers between 0-255
-            color = str(entity.color)
-            if str.isnumeric(color):
-                subs['COLOR'] = int(color) % 256
-
         if hasattr(entity, 'layer'):
             subs['LAYER'] = str(entity.layer)
 
diff --git a/trimesh/path/path.py b/trimesh/path/path.py
index 8e41c6df..849caf77 100644
--- a/trimesh/path/path.py
+++ b/trimesh/path/path.py
@@ -10,7 +10,7 @@ import collections
 
 from ..points import plane_fit
 from ..geometry import plane_transform
-
+from ..visual import to_rgba
 from ..constants import log
 from ..constants import tol_path as tol
 
@@ -68,7 +68,8 @@ class Path(object):
                  entities=None,
                  vertices=None,
                  metadata=None,
-                 process=True):
+                 process=True,
+                 colors=None):
         """
         Instantiate a path object.
 
@@ -86,11 +87,15 @@ class Path(object):
 
         self.entities = entities
         self.vertices = vertices
-        self.metadata = dict()
 
+        # assign each color to each entity
+        self.colors = colors
+        # collect metadata into new dictionary
+        self.metadata = dict()
         if metadata.__class__.__name__ == 'dict':
             self.metadata.update(metadata)
 
+        # cache will dump whenever self.crc changes
         self._cache = caching.Cache(id_function=self.crc)
 
         if process:
@@ -117,6 +122,48 @@ class Path(object):
                 func()
         return self
 
+    @property
+    def colors(self):
+        """
+        Colors are stored per-entity.
+
+        Returns
+        ------------
+        colors : (len(entities), 4) uint8
+          RGBA colors for each entity
+        """
+        # start with default colors
+        colors = np.ones((len(self.entities), 4))
+        colors = (colors * [100, 100, 100, 255]).astype(np.uint8)
+        # collect colors from entities
+        for i, e in enumerate(self.entities):
+            if hasattr(e, 'color') and e.color is not None:
+                colors[i] = to_rgba(e.color)
+        # don't allow parts of the color array to be written
+        colors.flags['WRITEABLE'] = False
+        return colors
+
+    @colors.setter
+    def colors(self, values):
+        """
+        Set the color for every entity in the Path.
+
+        Parameters
+        ------------
+        values : (len(entities), 4) uint8
+          Color of each entity
+        """
+        # if not set return
+        if values is None:
+            return
+        # make sure colors are RGBA
+        colors = to_rgba(values)
+        if len(colors) != len(self.entities):
+            raise ValueError('colors must be per-entity!')
+        # otherwise assign each color to the entity
+        for c, e in zip(colors, self.entities):
+            e.color = c
+
     @property
     def vertices(self):
         return self._vertices
diff --git a/trimesh/path/segments.py b/trimesh/path/segments.py
index 64154f8a..36baca49 100644
--- a/trimesh/path/segments.py
+++ b/trimesh/path/segments.py
@@ -363,3 +363,100 @@ def extrude(segments, height, double_sided=False):
             faces, np.fliplr(faces)))
 
     return vertices, faces
+
+
+def length(segments, summed=True):
+    """
+    Extrude 2D line segments into 3D triangles.
+
+    Parameters
+    -------------
+    segments : (n, 2, 2) float
+      2D line segments
+    height : float
+      Distance to extrude along Z
+    double_sided : bool
+      If true, return 4 triangles per segment
+
+    Returns
+    -------------
+    vertices : (n, 3) float
+      Vertices in space
+    faces : (n, 3) int
+      Indices of vertices forming triangles
+    """
+    segments = np.asanyarray(segments)
+    norms = util.row_norm(segments[:, 0, :] - segments[:, 1, :])
+    if summed:
+        return norms.sum()
+    return norms
+
+
+def resample(segments, maxlen):
+    """
+    Resample line segments until no segment
+    is longer than maxlen.
+
+    Parameters
+    -------------
+    segments : (n, 2, 2) float
+      2D line segments
+    maxlen : float
+      The maximum length of a line segment
+
+    Returns
+    -------------
+    resampled : (m, 2, 3) float
+      Line segments where no segment is longer than maxlen
+    """
+    maxlen = float(maxlen)
+    segments = np.asanyarray(segments, dtype=np.float64)
+
+    # shortcut for endpoints
+    pt1 = segments[:, 0]
+    pt2 = segments[:, 1]
+    # vector between endpoints
+    vec = pt2 - pt1
+    # the integer number of times a segment needs to be split
+    splits = np.ceil(util.row_norm(vec) / maxlen).astype(np.int64)
+
+    # save resulting segments
+    result = []
+
+    # loop through each count of unique splits needed
+    for split in np.unique(splits):
+        # get a mask of which segments need to be split
+        mask = splits == split
+        # the vector for each incremental length
+        increment = vec[mask] / split
+        # stack the increment vector into the shape needed
+        v = np.tile(increment, split + 1).reshape((-1, 3))
+        # apply integer multiples of the increment
+        v *= np.tile(np.arange(split + 1),
+                     len(increment)).reshape((-1, 1))
+        # stack the origin points correctly
+        o = np.tile(pt1[mask], split + 1).reshape((-1, 3))
+        # now get each segment as an (split, 3) polyline
+        poly = (o + v).reshape((-1, split + 1, 3))
+        # get indexes to stack polyline into segments
+        stack = util.stack_lines(np.arange(split + 1))
+        # save the resulting segments
+        result.extend([p[stack] for p in poly])
+
+        if tol.strict:
+            # check to make sure every start and end point
+            # from the reconstructed result corresponds
+            for original, recon in zip(segments[mask], poly):
+                assert np.allclose(original[0], recon[0])
+                assert np.allclose(original[-1], recon[-1])
+
+    # stack into (n, 2, 3) segments
+    result = np.concatenate(result)
+
+    if tol.strict:
+        # make sure resampled segments have the same length as input
+        assert np.isclose(length(segments),
+                          length(result),
+                          atol=1e-3)
+
+    return result
diff --git a/trimesh/rendering.py b/trimesh/rendering.py
index 89df649f..f6031e97 100644
--- a/trimesh/rendering.py
+++ b/trimesh/rendering.py
@@ -52,7 +52,8 @@ def convert_to_vertexlist(geometry, **kwargs):
     elif util.is_instance_named(geometry, 'Path'):
         # works for Path3D and Path2D
         # both of which inherit from Path
-        return path_to_vertexlist(geometry, **kwargs)
+        return path_to_vertexlist(geometry,
+                                  **kwargs)
     elif util.is_instance_named(geometry, 'PointCloud'):
         # pointcloud objects contain colors
         return points_to_vertexlist(geometry.vertices,
@@ -154,7 +155,7 @@ def mesh_to_vertexlist(mesh,
     return args
 
 
-def path_to_vertexlist(path, group=None, colors=None, **kwargs):
+def path_to_vertexlist(path, group=None, **kwargs):
     """
     Convert a Path3D object to arguments for a
     pyglet indexed vertex list constructor.
@@ -175,24 +176,32 @@ def path_to_vertexlist(path, group=None, colors=None, **kwargs):
     vertices = path.vertices
 
     # get (n, 2, (2|3)) lines
-    lines = np.vstack([util.stack_lines(e.discrete(vertices))
-                       for e in path.entities])
+    stacked = [util.stack_lines(e.discrete(vertices))
+               for e in path.entities]
+    lines = util.vstack_empty(stacked)
     count = len(lines)
 
     # stack zeros for 2D lines
     if util.is_shape(vertices, (-1, 2)):
         lines = lines.reshape((-1, 2))
         lines = np.column_stack((lines, np.zeros(len(lines))))
-
     # index for GL is one per point
     index = np.arange(count).tolist()
-
+    # convert from entity color to the color of
+    # each vertex in the line segments
+    vcolor = np.vstack(
+        [(np.ones((len(s), 4)) * c).astype(np.uint8)
+         for s, c in zip(stacked, path.colors)])
+    # convert to gl-friendly colors
+    gl_colors = colors_to_gl(vcolor, count=count)
+
+    # collect args for vertexlist constructor
     args = (count,    # number of lines
             GL_LINES,  # mode
             group,    # group
             index,    # indices
             ('v3f/static', lines.reshape(-1)),
-            colors_to_gl(colors, count=count))  # default colors
+            gl_colors)
     return args
 
 
@@ -238,7 +247,8 @@ def points_to_vertexlist(points,
 
 def colors_to_gl(colors, count):
     """
-    Given a list of colors (or None) return a GL-acceptable list of colors
+    Given a list of colors (or None) return a GL-acceptable
+    list of colors.
 
     Parameters
     ------------
diff --git a/trimesh/scene/transforms.py b/trimesh/scene/transforms.py
index 33141834..edccf6ce 100644
--- a/trimesh/scene/transforms.py
+++ b/trimesh/scene/transforms.py
@@ -147,7 +147,7 @@ class TransformForest(object):
         # start with base frame as first node index
         result = [{'name': self.base_frame}]
         # {node name : node index in gltf}
-        lookup = {0: self.base_frame}
+        lookup = {self.base_frame: 0}
 
         # collect the nodes in order
         for node in node_data.keys():
diff --git a/trimesh/util.py b/trimesh/util.py
index 4391f068..ca31eca5 100644
--- a/trimesh/util.py
+++ b/trimesh/util.py
@@ -1018,7 +1018,9 @@ def stack_lines(indices):
 
     """
     indices = np.asanyarray(indices)
-    if is_sequence(indices[0]):
+    if len(indices) == 0:
+        return np.array([])
+    elif is_sequence(indices[0]):
         shape = (-1, len(indices[0]))
     else:
         shape = (-1, 2)
diff --git a/trimesh/version.py b/trimesh/version.py
index b51de07f..01bd03ce 100644
--- a/trimesh/version.py
+++ b/trimesh/version.py
@@ -1,1 +1,1 @@
-__version__ = '3.4.10'
+__version__ = '3.5.0'
diff --git a/trimesh/viewer/widget.py b/trimesh/viewer/widget.py
index 33037d69..404258ab 100644
--- a/trimesh/viewer/widget.py
+++ b/trimesh/viewer/widget.py
@@ -138,7 +138,7 @@ class SceneWidget(glooey.Widget):
         self.vertex_list_hash = {}
         self.textures = {}
 
-        self._initial_camera_transform = self.scene.camera_transform
+        self._initial_camera_transform = self.scene.camera_transform.copy()
         self.reset_view()
 
         self._background = kwargs.pop('background', None)
@@ -159,6 +159,15 @@ class SceneWidget(glooey.Widget):
             )
         return self._scene_group
 
+    def clear(self):
+        self._scene_group = None
+        self.mesh_group = {}
+        while self.vertex_list:
+            _, vertex = self.vertex_list.popitem()
+            vertex.delete()
+        self.vertex_list_hash = {}
+        self.textures = {}
+
     def reset_view(self):
         self.view = {
             'ball': Trackball(
@@ -166,7 +175,7 @@ class SceneWidget(glooey.Widget):
                 size=self.scene.camera.resolution,
                 scale=self.scene.scale,
                 target=self.scene.centroid)}
-        self.scene.camera_transform = self.view['ball'].pose
+        self.scene.camera_transform[...] = self.view['ball'].pose
 
     def do_claim(self):
         return 0, 0
diff --git a/trimesh/viewer/windowed.py b/trimesh/viewer/windowed.py
index 2b11484a..4b54dbb3 100644
--- a/trimesh/viewer/windowed.py
+++ b/trimesh/viewer/windowed.py
@@ -527,7 +527,7 @@ class SceneViewer(pyglet.window.Window):
         width, height = self._update_perspective(width, height)
         self.scene.camera.resolution = (width, height)
         self.view['ball'].resize(self.scene.camera.resolution)
-        self.scene.camera_transform = self.view['ball'].pose
+        self.scene.camera_transform[...] = self.view['ball'].pose
 
     def on_mouse_press(self, x, y, buttons, modifiers):
         """
@@ -549,21 +549,21 @@ class SceneViewer(pyglet.window.Window):
             self.view['ball'].set_state(Trackball.STATE_ZOOM)
 
         self.view['ball'].down(np.array([x, y]))
-        self.scene.camera_transform = self.view['ball'].pose
+        self.scene.camera_transform[...] = self.view['ball'].pose
 
     def on_mouse_drag(self, x, y, dx, dy, buttons, modifiers):
         """
         Pan or rotate the view.
         """
         self.view['ball'].drag(np.array([x, y]))
-        self.scene.camera_transform = self.view['ball'].pose
+        self.scene.camera_transform[...] = self.view['ball'].pose
 
     def on_mouse_scroll(self, x, y, dx, dy):
         """
         Zoom the view.
         """
         self.view['ball'].scroll(dy)
-        self.scene.camera_transform = self.view['ball'].pose
+        self.scene.camera_transform[...] = self.view['ball'].pose
 
     def on_key_press(self, symbol, modifiers):
         """
@@ -601,7 +601,7 @@ class SceneViewer(pyglet.window.Window):
                 self.view['ball'].drag([0, -magnitude])
             elif symbol == pyglet.window.key.UP:
                 self.view['ball'].drag([0, magnitude])
-            self.scene.camera_transform = self.view['ball'].pose
+            self.scene.camera_transform[...] = self.view['ball'].pose
 
     def on_draw(self):
         """
diff --git a/trimesh/voxel/ops.py b/trimesh/voxel/ops.py
index 2cdc7005..aafd2587 100644
--- a/trimesh/voxel/ops.py
+++ b/trimesh/voxel/ops.py
@@ -255,7 +255,7 @@ def multibox(centers, pitch=1.0, colors=None):
     # get a basic box
     b = primitives.Box()
     # apply the pitch
-    b.vertices *= float(pitch)
+    b.apply_scale(float(pitch))
     # tile into one box vertex per center
     v = np.tile(
         centers,


Test patch:
diff --git a/tests/generic.py b/tests/generic.py
index 091e0f2c..a5afc82d 100644
--- a/tests/generic.py
+++ b/tests/generic.py
@@ -300,6 +300,8 @@ def check_path2D(path):
     assert all(path.polygons_closed[i] is not None
                for i in path.enclosure_directed.nodes())
 
+    assert path.colors.shape == (len(path.entities), 4)
+
 
 def scene_equal(a, b):
     """
diff --git a/tests/test_paths.py b/tests/test_paths.py
index 678661a1..afd30013 100644
--- a/tests/test_paths.py
+++ b/tests/test_paths.py
@@ -166,6 +166,8 @@ class VectorTests(g.unittest.TestCase):
                 raise ValueError(
                     'type bases:',
                     [i.__name__ for i in g.trimesh.util.type_bases(t)])
+        # make sure this doesn't crash with text entities
+        g.trimesh.rendering.convert_to_vertexlist(p)
 
     def test_empty(self):
         # make sure empty paths perform as expected
@@ -261,6 +263,18 @@ class VectorTests(g.unittest.TestCase):
             s = path.sample(count=count)
             assert s.shape[1] == 2
 
+    def test_color(self):
+        p = g.get_mesh('2D/wrench.dxf')
+        # make sure we have entities
+        assert len(p.entities) > 0
+        # make sure shape of colors is correct
+        assert p.colors.shape == (len(p.entities), 4)
+        color = [255, 0, 0, 255]
+        # assign a color to the entity
+        p.entities[0].color = color
+        # make sure this is reflected in the path color
+        assert g.np.allclose(p.colors[0], color)
+
 
 class SplitTest(g.unittest.TestCase):
 
diff --git a/tests/test_segments.py b/tests/test_segments.py
index e0a05e10..a7d081fa 100644
--- a/tests/test_segments.py
+++ b/tests/test_segments.py
@@ -60,6 +60,21 @@ class SegmentsTest(g.unittest.TestCase):
             # compare area of mesh with source path
             assert g.np.isclose(mesh.area, path.length * height)
 
+    def test_resample(self):
+        from trimesh.path.segments import length, resample
+        # create some random segments
+        seg = g.random((1000, 2, 3))
+        # set a maximum segment length
+        maxlen = 0.1
+        # one of the original segments should be longer than maxlen
+        assert (length(seg, summed=False) > maxlen).any()
+        # resample to be all shorter than maxlen
+        res = resample(seg, maxlen=maxlen)
+        # check lengths of the resampled result
+        assert (length(res, summed=False) < maxlen).all()
+        # make sure overall length hasn't changed
+        assert g.np.isclose(length(res), length(seg))
+
 
 if __name__ == '__main__':
     g.trimesh.util.attach_to_log()