"""
path.py
-----------

A module designed to work with vector paths such as
those stored in a DXF or SVG file.
"""
import numpy as np

import copy
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

from .util import concatenate

from .. import parent
from .. import util
from .. import units
from .. import bounds
from .. import caching
from .. import grouping
from .. import exceptions
from .. import transformations as tf

from . import raster
from . import repair
from . import simplify
from . import creation  # NOQA
from . import polygons
from . import segments  # NOQA
from . import traversal

from .exchange.export import export_path

from scipy.spatial import cKDTree
from shapely.geometry import Polygon

try:
    import networkx as nx
except BaseException as E:
    # create a dummy module which will raise the ImportError
    # or other exception only when someone tries to use networkx
    nx = exceptions.ExceptionModule(E)


class Path(parent.Geometry):
    """
    A Path object consists of vertices and entities. Vertices
    are a simple (n, dimension) float array of points in space.

    Entities are a list of objects representing geometric
    primitives, such as Lines, Arcs, BSpline, etc. All entities
    reference vertices by index, so any transform applied to the
    simple vertex array is applied to the entity.
    """

    def __init__(self,
                 entities=None,
                 vertices=None,
                 metadata=None,
                 process=True,
                 colors=None,
                 **kwargs):
        """
        Instantiate a path object.

        Parameters
        -----------
        entities : (m,) trimesh.path.entities.Entity
          Contains geometric entities
        vertices : (n, dimension) float
          The vertices referenced by entities
        metadata : dict
          Any metadata about the path
        process :  bool
          Run simple cleanup or not
        """

        self.entities = entities
        self.vertices = vertices

        # 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:
            # literally nothing will work if vertices
            # aren't merged properly
            self.merge_vertices()

    def __repr__(self):
        """
        Print a quick summary of the number of vertices and entities.
        """
        return '<trimesh.{}(vertices.shape={}, len(entities)={})>'.format(
            type(self).__name__,
            self.vertices.shape,
            len(self.entities))

    def process(self):
        """
        Apply basic cleaning functions to the Path object in- place.
        """
        with self._cache:
            for func in self._process_functions():
                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

    def colors_crc(self):
        """
        A CRC of the current entity colors.

        Returns
        -------
        crc : int
          CRC of the current entity colors
        """
        # first CRC the colors in every entity
        target = caching.crc32(bytes().join(
            e.color.tobytes() for e in self.entities))
        return target

    @property
    def vertices(self):
        return self._vertices

    @vertices.setter
    def vertices(self, values):
        self._vertices = caching.tracked_array(
            values, dtype=np.float64)

    @property
    def entities(self):
        """
        The actual entities making up the path.

        Returns
        -----------
        entities : (n,) trimesh.path.entities.Entity
          Entities such as Line, Arc, or BSpline curves
        """
        return self._entities

    @entities.setter
    def entities(self, values):
        if values is None:
            self._entities = np.array([])
        else:
            self._entities = np.asanyarray(values)

    @property
    def layers(self):
        """
        Get a list of the layer for every entity.

        Returns
        ---------
        layers : (len(entities), ) any
          Whatever is stored in each `entity.layer`
        """
        # layer is a required property for entities
        layers = [e.layer for e in self.entities]
        return layers

    def crc(self):
        """
        A CRC of the current vertices and entities.

        Returns
        ------------
        crc : int
          CRC of entity points and vertices
        """
        # first CRC the points in every entity
        target = caching.crc32(bytes().join(
            e._bytes() for e in self.entities))
        # XOR the CRC for the vertices
        target ^= self.vertices.fast_hash()
        return target

    def md5(self):
        """
        An MD5 hash of the current vertices and entities.

        Returns
        ------------
        md5 : str
          Appended MD5 hashes
        """
        # first MD5 the points in every entity
        target = '{}{}'.format(
            util.md5_object(bytes().join(
                e._bytes() for e in self.entities)),
            self.vertices.md5())

        return target

    @caching.cache_decorator
    def paths(self):
        """
        Sequence of closed paths, encoded by entity index.

        Returns
        ---------
        paths : (n,) sequence of (*,) int
          Referencing self.entities
        """
        paths = traversal.closed_paths(self.entities,
                                       self.vertices)
        return paths

    @caching.cache_decorator
    def dangling(self):
        """
        List of entities that aren't included in a closed path

        Returns
        ----------
        dangling : (n,) int
          Index of self.entities
        """
        if len(self.paths) == 0:
            return np.arange(len(self.entities))
        else:
            included = np.hstack(self.paths)
        dangling = np.setdiff1d(np.arange(len(self.entities)),
                                included)
        return dangling

    @caching.cache_decorator
    def kdtree(self):
        """
        A KDTree object holding the vertices of the path.

        Returns
        ----------
        kdtree : scipy.spatial.cKDTree
          Object holding self.vertices
        """
        kdtree = cKDTree(self.vertices.view(np.ndarray))
        return kdtree

    @property
    def scale(self):
        """
        What is a representitive number that reflects the magnitude
        of the world holding the paths, for numerical comparisons.

        Returns
        ----------
        scale : float
          Approximate size of the world holding this path
        """
        # use vertices peak-peak rather than exact extents
        scale = float((self.vertices.ptp(axis=0) ** 2).sum() ** .5)
        return scale

    @caching.cache_decorator
    def length(self):
        """
        The total discretized length of every entity.

        Returns
        --------
        length: float, summed length of every entity
        """
        length = float(sum(i.length(self.vertices)
                           for i in self.entities))
        return length

    @caching.cache_decorator
    def bounds(self):
        """
        Return the axis aligned bounding box of the current path.

        Returns
        ----------
        bounds : (2, dimension) float
          AABB with (min, max) coordinates
        """
        # get the exact bounds of each entity
        # some entities (aka 3- point Arc) have bounds that can't
        # be generated from just bound box of vertices

        points = np.array([e.bounds(self.vertices)
                           for e in self.entities],
                          dtype=np.float64)

        # flatten bound extrema into (n, dimension) array
        points = points.reshape((-1, self.vertices.shape[1]))
        # get the max and min of all bounds
        return np.array([points.min(axis=0),
                         points.max(axis=0)],
                        dtype=np.float64)

    @caching.cache_decorator
    def centroid(self):
        """
        Return the centroid of axis aligned bounding box enclosing
        all entities of the path object.

        Returns
        -----------
        centroid : (d,) float
          Approximate centroid of the path
        """
        centroid = self.bounds.mean(axis=0)
        return centroid

    @property
    def extents(self):
        """
        The size of the axis aligned bounding box.

        Returns
        ---------
        extents : (dimension,) float
          Edge length of AABB
        """
        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):
        """
        Convert the units of the current drawing in place.

        Parameters
        -----------
        desired : str
          Unit system to convert to
        guess : bool
          If True will attempt to guess units
        """
        units._convert_units(self,
                             desired=desired,
                             guess=guess)

    def explode(self):
        """
        Turn every multi- segment entity into single segment
        entities in- place.
        """
        new_entities = []
        for entity in self.entities:
            # explode into multiple entities
            new_entities.extend(entity.explode())
        # avoid setter and assign new entities
        self._entities = np.array(new_entities)
        # explicitly clear cache
        self._cache.clear()

    def fill_gaps(self, distance=0.025):
        """
        Find vertices without degree 2 and try to connect to
        other vertices. Operations are done in-place.

        Parameters
        ----------
        distance : float
          Connect vertices up to this distance
        """
        repair.fill_gaps(self, distance=distance)

    @property
    def is_closed(self):
        """
        Are all entities connected to other entities.

        Returns
        -----------
        closed : bool
          Every entity is connected at its ends
        """
        closed = all(i == 2 for i in
                     dict(self.vertex_graph.degree()).values())

        return closed

    @property
    def is_empty(self):
        """
        Are any entities defined for the current path.

        Returns
        ----------
        empty : bool
          True if no entities are defined
        """
        return len(self.entities) == 0

    @caching.cache_decorator
    def vertex_graph(self):
        """
        Return a networkx.Graph object for the entity connectiviy

        graph : networkx.Graph
          Holds vertex indexes
        """
        graph, closed = traversal.vertex_graph(self.entities)
        return graph

    @caching.cache_decorator
    def vertex_nodes(self):
        """
        Get a list of which vertex indices are nodes,
        which are either endpoints or points where the
        entity makes a direction change.

        Returns
        --------------
        nodes : (n, 2) int
          Indexes of self.vertices which are nodes
        """
        nodes = np.vstack([e.nodes for e in self.entities])
        return nodes

    def apply_transform(self, transform):
        """
        Apply a transformation matrix to the current path in- place

        Parameters
        -----------
        transform : (d+1, d+1) float
          Homogeneous transformations for vertices
        """
        dimension = self.vertices.shape[1]
        transform = np.asanyarray(transform, dtype=np.float64)

        if transform.shape != (dimension + 1, dimension + 1):
            raise ValueError('transform is incorrect shape!')
        elif np.abs(transform - np.eye(dimension + 1)).max() < 1e-8:
            # if we've been passed an identity matrix do nothing
            return

        # make sure cache is up to date
        self._cache.verify()
        # new cache to transfer items
        cache = {}
        # apply transform to discretized paths
        if 'discrete' in self._cache.cache:
            cache['discrete'] = [tf.transform_points(
                d, matrix=transform) for d in self.discrete]

        # things we can just straight up copy
        # as they are topological not geometric
        for key in ['root',
                    'paths',
                    'path_valid',
                    'dangling',
                    'vertex_graph',
                    'enclosure',
                    'enclosure_shell',
                    'enclosure_directed']:
            # if they're in cache save them from the purge
            if key in self._cache.cache:
                cache[key] = self._cache.cache[key]

        # transform vertices in place
        self.vertices = tf.transform_points(
            self.vertices,
            matrix=transform)
        # explicitly clear the cache
        self._cache.clear()
        self._cache.id_set()

        # populate the things we wangled
        self._cache.cache.update(cache)
        return self

    def apply_layer(self, name):
        """
        Apply a layer name to every entity in the path.

        Parameters
        ------------
        name : str
          Apply layer name to every entity
        """
        for e in self.entities:
            e.layer = name

    def rezero(self):
        """
        Translate so that every vertex is positive in the current
        mesh is positive.

        Returns
        -----------
        matrix : (dimension + 1, dimension + 1) float
          Homogeneous transformations that was applied
          to the current Path object.
        """
        # transform to the lower left corner
        matrix = tf.translation_matrix(-self.bounds[0])
        # cleanly apply trransformation matrix
        self.apply_transform(matrix)

        return matrix

    def merge_vertices(self, digits=None):
        """
        Merges vertices which are identical and replace references
        by altering `self.entities` and `self.vertices`

        Parameters
        --------------
        digits : None, or int
          How many digits to consider when merging vertices
        """
        if len(self.vertices) == 0:
            return
        if digits is None:
            digits = util.decimal_to_digits(
                tol.merge * self.scale,
                min_digits=1)

        unique, inverse = grouping.unique_rows(
            self.vertices, digits=digits)
        self.vertices = self.vertices[unique]

        entities_ok = np.ones(len(self.entities), dtype=np.bool)

        for index, entity in enumerate(self.entities):
            # what kind of entity are we dealing with
            kind = type(entity).__name__

            # entities that don't need runs merged
            # don't screw up control- point- knot relationship
            if kind in 'BSpline Bezier Text':
                entity.points = inverse[entity.points]
                continue
            # if we merged duplicate vertices, the entity may
            # have multiple references to the same vertex
            points = grouping.merge_runs(inverse[entity.points])
            # if there are three points and two are identical fix it
            if kind == 'Line':
                if len(points) == 3 and points[0] == points[-1]:
                    points = points[:2]
                elif len(points) < 2:
                    # lines need two or more vertices
                    entities_ok[index] = False
            elif kind == 'Arc' and len(points) != 3:
                # three point arcs need three points
                entities_ok[index] = False

            # store points in entity
            entity.points = points

        # remove degenerate entities
        self.entities = self.entities[entities_ok]

    def replace_vertex_references(self, mask):
        """
        Replace the vertex index references in every entity.

        Parameters
        ------------
        mask : (len(self.vertices), ) int
          Contains new vertex indexes

        Alters
        ------------
        entity.points in self.entities
          Replaced by mask[entity.points]
        """
        for entity in self.entities:
            entity.points = mask[entity.points]

    def remove_entities(self, entity_ids):
        """
        Remove entities by index.

        Parameters
        -----------
        entity_ids : (n,) int
          Indexes of self.entities to remove
        """
        if len(entity_ids) == 0:
            return
        keep = np.ones(len(self.entities), dtype=bool)
        keep[entity_ids] = False
        self.entities = self.entities[keep]

    def remove_invalid(self):
        """
        Remove entities which declare themselves invalid

        Alters
        ----------
        self.entities: shortened
        """
        valid = np.array([i.is_valid for i in self.entities],
                         dtype=np.bool)
        self.entities = self.entities[valid]

    def remove_duplicate_entities(self):
        """
        Remove entities that are duplicated

        Alters
        -------
        self.entities: length same or shorter
        """
        entity_hashes = np.array([hash(i) for i in self.entities])
        unique, inverse = grouping.unique_rows(entity_hashes)
        if len(unique) != len(self.entities):
            self.entities = self.entities[unique]

    @caching.cache_decorator
    def referenced_vertices(self):
        """
        Which vertices are referenced by an entity.

        Returns
        -----------
        referenced_vertices: (n,) int, indexes of self.vertices
        """
        # no entities no reference
        if len(self.entities) == 0:
            return np.array([], dtype=np.int64)
        referenced = np.concatenate([e.points for e in self.entities])
        referenced = np.unique(referenced.astype(np.int64))

        return referenced

    def remove_unreferenced_vertices(self):
        """
        Removes all vertices which aren't used by an entity.

        Alters
        ---------
        self.vertices: reordered and shortened
        self.entities: entity.points references updated
        """

        unique = self.referenced_vertices

        mask = np.ones(len(self.vertices), dtype=np.int64) * -1
        mask[unique] = np.arange(len(unique), dtype=np.int64)

        self.replace_vertex_references(mask=mask)
        self.vertices = self.vertices[unique]

    def discretize_path(self, path):
        """
        Given a list of entities, return a list of connected points.

        Parameters
        -----------
        path: (n,) int, indexes of self.entities

        Returns
        -----------
        discrete: (m, dimension)
        """
        discrete = traversal.discretize_path(self.entities,
                                             self.vertices,
                                             path,
                                             scale=self.scale)
        return discrete

    @caching.cache_decorator
    def discrete(self):
        """
        A sequence of connected vertices in space, corresponding to
        self.paths.

        Returns
        ---------
        discrete : (len(self.paths),)
            A sequence of (m*, dimension) float
        """
        discrete = [self.discretize_path(i)
                    for i in self.paths]
        return discrete

    def export(self,
               file_obj=None,
               file_type=None,
               **kwargs):
        """
        Export the path to a file object or return data.

        Parameters
        ---------------
        file_obj : None, str, or file object
          File object or string to export to
        file_type : None or str
          Type of file: dxf, dict, svg

        Returns
        ---------------
        exported : bytes or str
          Exported as specified type
        """
        return export_path(self,
                           file_type=file_type,
                           file_obj=file_obj,
                           **kwargs)

    def to_dict(self):
        export_dict = self.export(file_type='dict')
        return export_dict

    def copy(self):
        """
        Get a copy of the current mesh

        Returns
        ---------
        copied : Path object
          Copy of self
        """

        metadata = {}
        # grab all the keys into a list so if something is added
        # in another thread it probably doesn't stomp on our loop
        for key in list(self.metadata.keys()):
            try:
                metadata[key] = copy.deepcopy(self.metadata[key])
            except RuntimeError:
                # multiple threads
                log.warning('key {} changed during copy'.format(key))

        # copy the core data
        copied = type(self)(entities=copy.deepcopy(self.entities),
                            vertices=copy.deepcopy(self.vertices),
                            metadata=metadata,
                            process=False)

        cache = {}
        # try to copy the cache over to the new object
        try:
            # save dict keys before doing slow iteration
            keys = list(self._cache.cache.keys())
            # run through each key and copy into new cache
            for k in keys:
                cache[k] = copy.deepcopy(self._cache.cache[k])
        except RuntimeError:
            # if we have multiple threads this may error and is NBD
            log.debug('unable to copy cache')
        except BaseException:
            # catch and log errors we weren't expecting
            log.error('unable to copy cache', exc_info=True)
        copied._cache.cache = cache
        copied._cache.id_set()

        return copied

    def scene(self):
        """
        Get a scene object containing the current Path3D object.

        Returns
        --------
        scene: trimesh.scene.Scene object containing current path
        """
        from ..scene import Scene
        scene = Scene(self)
        return scene

    def __add__(self, other):
        """
        Concatenate two Path objects by appending vertices and
        reindexing point references.

        Parameters
        -----------
        other: Path object

        Returns
        -----------
        concat: Path object, appended from self and other
        """
        concat = concatenate([self, other])
        return concat


class Path3D(Path):
    """
    Hold multiple vector curves (lines, arcs, splines, etc) in 3D.
    """

    def _process_functions(self):
        return [self.merge_vertices,
                self.remove_duplicate_entities,
                self.remove_unreferenced_vertices]

    def to_planar(self,
                  to_2D=None,
                  normal=None,
                  check=True):
        """
        Check to see if current vectors are all coplanar.

        If they are, return a Path2D and a transform which will
        transform the 2D representation back into 3 dimensions

        Parameters
        -----------
        to_2D: (4,4) float
            Homogeneous transformation matrix to apply,
            If not passed a plane will be fitted to vertices.
        normal: (3,) float, or None
           Approximate normal of direction of plane
           If to_2D is not specified sign
           will be applied to fit plane normal
        check:  bool
            If True: Raise a ValueError if
            points aren't coplanar

        Returns
        -----------
        planar : trimesh.path.Path2D
                   Current path transformed onto plane
        to_3D :  (4,4) float
                   Homeogenous transformations to move planar
                   back into 3D space
        """
        # which vertices are actually referenced
        referenced = self.referenced_vertices
        # if nothing is referenced return an empty path
        if len(referenced) == 0:
            return Path2D(), np.eye(4)

        # no explicit transform passed
        if to_2D is None:
            # fit a plane to our vertices
            C, N = plane_fit(self.vertices[referenced])
            # apply the normal sign hint
            if normal is not None:
                normal = np.asanyarray(normal, dtype=np.float64)
                if normal.shape == (3,):
                    N *= np.sign(np.dot(N, normal))
                    N = normal
                else:
                    log.warning(
                        "passed normal not used: {}".format(
                            normal.shape))
            # create a transform from fit plane to XY
            to_2D = plane_transform(origin=C,
                                    normal=N)

        # make sure we've extracted a transform
        to_2D = np.asanyarray(to_2D, dtype=np.float64)
        if to_2D.shape != (4, 4):
            raise ValueError('unable to create transform!')

        # transform all vertices to 2D plane
        flat = tf.transform_points(self.vertices,
                                   to_2D)

        # Z values of vertices which are referenced
        heights = flat[referenced][:, 2]
        # points are not on a plane because Z varies
        if heights.ptp() > tol.planar:
            # since Z is inconsistent set height to zero
            height = 0.0
            if check:
                raise ValueError('points are not flat!')
        else:
            # if the points were planar store the height
            height = heights.mean()

        # the transform from 2D to 3D
        to_3D = np.linalg.inv(to_2D)

        # if the transform didn't move the path to
        # exactly Z=0 adjust it so the returned transform does
        if np.abs(height) > tol.planar:
            # adjust to_3D transform by height
            adjust = tf.translation_matrix(
                [0, 0, height])
            # apply the height adjustment to_3D
            to_3D = np.dot(to_3D, adjust)

        # copy metadata to new object
        metadata = copy.deepcopy(self.metadata)
        # store transform we used to move it onto the plane
        metadata['to_3D'] = to_3D

        # create the Path2D with the same entities
        # and XY values of vertices projected onto the plane
        planar = Path2D(entities=copy.deepcopy(self.entities),
                        vertices=flat[:, :2],
                        metadata=metadata,
                        process=False)

        return planar, to_3D

    def show(self, **kwargs):
        """
        Show the current Path3D object.
        """
        scene = self.scene()
        return scene.show(**kwargs)


class Path2D(Path):
    """
    Hold multiple vector curves (lines, arcs, splines, etc) in 3D.
    """

    def show(self, annotations=True):
        """
        Plot the current Path2D object using matplotlib.
        """
        if self.is_closed:
            self.plot_discrete(show=True, annotations=annotations)
        else:
            self.plot_entities(show=True, annotations=annotations)

    def _process_functions(self):
        """
        Return a list of functions to clean up a Path2D
        """
        return [self.merge_vertices,
                self.remove_duplicate_entities,
                self.remove_unreferenced_vertices]

    def apply_obb(self):
        """
        Transform the current path so that its OBB is axis aligned
        and OBB center is at the origin.

        Returns
        -----------
        obb : (3, 3) float
          Homogeneous transformation matrix
        """
        matrix = self.obb
        self.apply_transform(matrix)
        return matrix

    def apply_scale(self, scale):
        """
        Apply a 2D scale to the current Path2D.

        Parameters
        -------------
        scale : float or (2,) float
          Scale to apply in-place.
        """
        matrix = np.eye(3)
        matrix[:2, :2] *= scale
        return self.apply_transform(matrix)

    @caching.cache_decorator
    def obb(self):
        """
        Get a transform that centers and aligns the OBB of the
        referenced vertices with the XY axis.

        Returns
        -----------
        obb : (3, 3) float
          Homogeneous transformation matrix
        """
        matrix = bounds.oriented_bounds_2D(
            self.vertices[self.referenced_vertices])[0]
        return matrix

    def rasterize(self,
                  pitch,
                  origin,
                  resolution=None,
                  fill=True,
                  width=None,
                  **kwargs):
        """
        Rasterize a Path2D object into a boolean image ("mode 1").

        Parameters
        ------------
        pitch:      float, length in model space of a pixel edge
        origin:     (2,) float, origin position in model space
        resolution: (2,) int, resolution in pixel space
        fill:       bool, if True will return closed regions as filled
        width:      int, if not None will draw outline this wide (pixels)

        Returns
        ------------
        raster: PIL.Image object, mode 1
        """
        image = raster.rasterize(self,
                                 pitch=pitch,
                                 origin=origin,
                                 resolution=resolution,
                                 fill=fill,
                                 width=width)
        return image

    def sample(self, count, **kwargs):
        """
        Use rejection sampling to generate random points inside a
        polygon.

        Parameters
        -----------
        count   : int
                    Number of points to return
                    If there are multiple bodies, there will
                    be up to count * bodies points returned
        factor  : float
                    How many points to test per loop
                    IE, count * factor
        max_iter : int,
                    Maximum number of intersection loops
                    to run, total points sampled is
                    count * factor * max_iter

        Returns
        -----------
        hit : (n, 2) float
               Random points inside polygon
        """

        poly = self.polygons_full
        if len(poly) == 0:
            samples = np.array([])
        elif len(poly) == 1:
            samples = polygons.sample(poly[0], count=count, **kwargs)
        else:
            samples = util.vstack_empty([
                polygons.sample(i, count=count, **kwargs)
                for i in poly])

        return samples

    @property
    def body_count(self):
        return len(self.root)

    def to_3D(self, transform=None):
        """
        Convert 2D path to 3D path on the XY plane.

        Parameters
        -------------
        transform : (4, 4) float
            If passed, will transform vertices.
            If not passed and 'to_3D' is in metadata
            that transform will be used.

        Returns
        -----------
        path_3D: Path3D version of current path
        """
        # if there is a stored 'to_3D' transform in metadata use it
        if transform is None and 'to_3D' in self.metadata:
            transform = self.metadata['to_3D']

        # copy vertices and stack with zeros from (n, 2) to (n, 3)
        vertices = np.column_stack((copy.deepcopy(self.vertices),
                                    np.zeros(len(self.vertices))))
        if transform is not None:
            vertices = tf.transform_points(vertices,
                                           transform)
        # make sure everything is deep copied
        path_3D = Path3D(entities=copy.deepcopy(self.entities),
                         vertices=vertices,
                         metadata=copy.deepcopy(self.metadata))
        return path_3D

    @caching.cache_decorator
    def polygons_closed(self):
        """
        Cycles in the vertex graph, as shapely.geometry.Polygons.
        These are polygon objects for every closed circuit, with no notion
        of whether a polygon is a hole or an area. Every polygon in this
        list will have an exterior, but NO interiors.

        Returns
        ---------
        polygons_closed: (n,) list of shapely.geometry.Polygon objects
        """
        # will attempt to recover invalid garbage geometry
        # and will be None if geometry is unrecoverable
        polys = polygons.paths_to_polygons(self.discrete)
        return polys

    @caching.cache_decorator
    def polygons_full(self):
        """
        A list of shapely.geometry.Polygon objects with interiors created
        by checking which closed polygons enclose which other polygons.

        Returns
        ---------
        full : (len(self.root),) shapely.geometry.Polygon
            Polygons containing interiors
        """
        # pre- allocate the list to avoid indexing problems
        full = [None] * len(self.root)
        # store the graph to avoid cache thrashing
        enclosure = self.enclosure_directed
        # store closed polygons to avoid cache hits
        closed = self.polygons_closed

        # loop through root curves
        for i, root in enumerate(self.root):
            # a list of multiple Polygon objects that
            # are fully contained by the root curve
            children = [closed[child]
                        for child in enclosure[root].keys()]
            # all polygons_closed are CCW, so for interiors reverse them
            holes = [np.array(p.exterior.coords)[::-1]
                     for p in children]
            # a single Polygon object
            shell = closed[root].exterior
            # create a polygon with interiors
            full[i] = polygons.repair_invalid(Polygon(shell=shell,
                                                      holes=holes))
        # so we can use advanced indexing
        full = np.array(full)

        return full

    @caching.cache_decorator
    def area(self):
        """
        Return the area of the polygons interior.

        Returns
        ---------
        area: float, total area of polygons minus interiors
        """
        area = float(sum(i.area for i in self.polygons_full))
        return area

    def extrude(self, height, **kwargs):
        """
        Extrude the current 2D path into a 3D mesh.

        Parameters
        ----------
        height: float, how far to extrude the profile
        kwargs: passed directly to meshpy.triangle.build:
                triangle.build(mesh_info,
                               verbose=False,
                               refinement_func=None,
                               attributes=False,
                               volume_constraints=True,
                               max_volume=None,
                               allow_boundary_steiner=True,
                               allow_volume_steiner=True,
                               quality_meshing=True,
                               generate_edges=None,
                               generate_faces=False,
                               min_angle=None)
        Returns
        --------
        mesh: trimesh object representing extruded polygon
        """
        from ..primitives import Extrusion
        result = [Extrusion(polygon=i, height=height, **kwargs)
                  for i in self.polygons_full]
        if len(result) == 1:
            return result[0]
        return result

    def triangulate(self, **kwargs):
        """
        Create a region- aware triangulation of the 2D path.

        Parameters
        -------------
        **kwargs : dict
          Passed to trimesh.creation.triangulate_polygon

        Returns
        -------------
        vertices : (n, 2) float
          2D vertices of triangulation
        faces : (n, 3) int
          Indexes of vertices for triangles
        """
        from ..creation import triangulate_polygon

        # append vertices and faces into sequence
        v_seq = []
        f_seq = []

        # loop through polygons with interiors
        for polygon in self.polygons_full:
            v, f = triangulate_polygon(polygon, **kwargs)
            v_seq.append(v)
            f_seq.append(f)

        return util.append_faces(v_seq, f_seq)

    def medial_axis(self, resolution=None, clip=None):
        """
        Find the approximate medial axis based
        on a voronoi diagram of evenly spaced points on the
        boundary of the polygon.

        Parameters
        ----------
        resolution : None or float
          Distance between each sample on the polygon boundary
        clip : None, or (2,) float
          Min, max number of samples

        Returns
        ----------
        medial : Path2D object
          Contains only medial axis of Path
        """
        if resolution is None:
            resolution = self.scale / 1000.0

        # convert the edges to Path2D kwargs
        from .exchange.misc import edges_to_path

        # edges and vertices
        edge_vert = [polygons.medial_axis(i, resolution, clip)
                     for i in self.polygons_full]
        # create a Path2D object for each region
        medials = [Path2D(**edges_to_path(
            edges=e, vertices=v)) for e, v in edge_vert]

        # get a single Path2D of medial axis
        medial = concatenate(medials)

        return medial

    def connected_paths(self, path_id, include_self=False):
        """
        Given an index of self.paths find other paths which
        overlap with that path.

        Parameters
        -----------
        path_id : int
          Index of self.paths
        include_self : bool
          Should the result include path_id or not

        Returns
        -----------
        path_ids :  (n, ) int
          Indexes of self.paths that overlap input path_id
        """
        if len(self.root) == 1:
            path_ids = np.arange(len(self.polygons_closed))
        else:
            path_ids = list(nx.node_connected_component(
                self.enclosure,
                path_id))
        if include_self:
            return np.array(path_ids)
        return np.setdiff1d(path_ids, [path_id])

    def simplify(self, **kwargs):
        """
        Return a version of the current path with colinear segments
        merged, and circles entities replacing segmented circular paths.

        Returns
        ---------
        simplified : Path2D object
        """
        return simplify.simplify_basic(self, **kwargs)

    def simplify_spline(self, smooth=.0002, verbose=False):
        """
        Convert paths into b-splines.

        Parameters
        -----------
        smooth : float
          How much the spline should smooth the curve
        verbose : bool
          Print detailed log messages

        Returns
        ------------
        simplified : Path2D
          Discrete curves replaced with splines
        """
        return simplify.simplify_spline(self,
                                        smooth=smooth,
                                        verbose=verbose)

    def split(self, **kwargs):
        """
        If the current Path2D consists of n 'root' curves,
        split them into a list of n Path2D objects

        Returns
        ----------
        split:  (n,) list of Path2D objects
          Each connected region and interiors
        """
        return traversal.split(self)

    def plot_discrete(self, show=False, annotations=True):
        """
        Plot the closed curves of the path.
        """
        import matplotlib.pyplot as plt
        axis = plt.gca()
        axis.set_aspect('equal', 'datalim')

        for i, points in enumerate(self.discrete):
            color = ['g', 'k'][i in self.root]
            axis.plot(*points.T, color=color)

        if annotations:
            for e in self.entities:
                if not hasattr(e, 'plot'):
                    continue
                e.plot(self.vertices)

        if show:
            plt.show()
        return axis

    def plot_entities(self, show=False, annotations=True, color=None):
        """
        Plot the entities of the path, with no notion of topology
        """
        import matplotlib.pyplot as plt
        # keep plot axis scaled the same
        axis = plt.gca()
        axis.set_aspect('equal', 'datalim')
        # hardcode a format for each entity type
        eformat = {'Line0': {'color': 'g', 'linewidth': 1},
                   'Line1': {'color': 'y', 'linewidth': 1},
                   'Arc0': {'color': 'r', 'linewidth': 1},
                   'Arc1': {'color': 'b', 'linewidth': 1},
                   'Bezier0': {'color': 'k', 'linewidth': 1},
                   'Bezier1': {'color': 'k', 'linewidth': 1},
                   'BSpline0': {'color': 'm', 'linewidth': 1},
                   'BSpline1': {'color': 'm', 'linewidth': 1}}
        for entity in self.entities:
            # if the entity has it's own plot method use it
            if annotations and hasattr(entity, 'plot'):
                entity.plot(self.vertices)
                continue
            # otherwise plot the discrete curve
            discrete = entity.discrete(self.vertices)
            # a unique key for entities
            e_key = entity.__class__.__name__ + str(int(entity.closed))

            fmt = eformat[e_key].copy()
            if color is not None:
                # passed color will override other optons
                fmt['color'] = color
            elif hasattr(entity, 'color'):
                # if entity has specified color use it
                fmt['color'] = entity.color
            axis.plot(*discrete.T, **fmt)
        if show:
            plt.show()

    @property
    def identifier(self):
        """
        A unique identifier for the path.

        Returns
        ---------
        identifier: (5,) float, unique identifier
        """
        if len(self.polygons_full) != 1:
            raise TypeError('Identifier only valid for single body')
        return polygons.polygon_hash(self.polygons_full[0])

    @caching.cache_decorator
    def identifier_md5(self):
        """
        Return an MD5 of the identifier
        """
        as_int = (self.identifier * 1e4).astype(np.int64)
        hashed = util.md5_object(as_int.tobytes(order='C'))
        return hashed

    @property
    def path_valid(self):
        """
        Returns
        ----------
        path_valid: (n,) bool, indexes of self.paths self.polygons_closed
                         which are valid polygons
        """
        valid = [i is not None for i in self.polygons_closed]
        valid = np.array(valid, dtype=np.bool)
        return valid

    @caching.cache_decorator
    def root(self):
        """
        Which indexes of self.paths/self.polygons_closed are root curves.
        Also known as 'shell' or 'exterior.

        Returns
        ---------
        root: (n,) int, list of indexes
        """
        populate = self.enclosure_directed  # NOQA
        return self._cache['root']

    @caching.cache_decorator
    def enclosure(self):
        """
        Networkx Graph object of polygon enclosure.
        """
        with self._cache:
            undirected = self.enclosure_directed.to_undirected()
        return undirected

    @caching.cache_decorator
    def enclosure_directed(self):
        """
        Networkx DiGraph of polygon enclosure
        """
        root, enclosure = polygons.enclosure_tree(self.polygons_closed)
        self._cache['root'] = root
        return enclosure

    @caching.cache_decorator
    def enclosure_shell(self):
        """
        A dictionary of path indexes which are 'shell' paths, and values
        of 'hole' paths.

        Returns
        ----------
        corresponding: dict, {index of self.paths of shell : [indexes of holes]}
        """
        pairs = [(r, self.connected_paths(r, include_self=False))
                 for r in self.root]
        # OrderedDict to maintain corresponding order
        corresponding = collections.OrderedDict(pairs)
        return corresponding
