hub/venv/lib/python3.7/site-packages/trimesh/path/path.py

1553 lines
46 KiB
Python

"""
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 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(object):
"""
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
@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'] = np.array([
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_scale(self, scale):
"""
Apply a transformation matrix to the current path in- place
Parameters
-----------
scale : float or (3,) float
Scale to be applied to mesh
"""
dimension = self.vertices.shape[1]
matrix = np.eye(dimension + 1)
matrix[:dimension, :dimension] *= scale
return self.apply_transform(matrix)
def apply_translation(self, offset):
"""
Apply a transformation matrix to the current path in- place
Parameters
-----------
offset : float or (3,) float
Translation to be applied to mesh
"""
# work on 2D and 3D paths
dimension = self.vertices.shape[1]
# make sure offset is correct length and type
offset = np.array(
offset, dtype=np.float64).reshape(dimension)
# create a homogeneous transform
matrix = np.eye(dimension + 1)
# apply the offset
matrix[:dimension, dimension] = offset
return self.apply_transform(matrix)
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))
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 = np.array([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)
def plot_discrete(self, show=False):
"""
Plot closed curves
Parameters
------------
show : bool
If False will not execute matplotlib.pyplot.show
"""
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D # NOQA
fig = plt.figure()
axis = fig.add_subplot(111, projection='3d')
for discrete in self.discrete:
axis.plot(*discrete.T)
if show:
plt.show()
def plot_entities(self, show=False):
"""
Plot discrete version of entities without regards
for connectivity.
Parameters
-------------
show : bool
If False will not execute matplotlib.pyplot.show
"""
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D # NOQA
fig = plt.figure()
axis = fig.add_subplot(111, projection='3d')
for entity in self.entities:
vertices = entity.discrete(self.vertices)
axis.plot(*vertices.T)
if show:
plt.show()
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
@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.axes()
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
plt.axes().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
plt.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.tostring(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