import copy import numpy as np from .util import is_ccw from .. import util from .. import grouping from .. import constants 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 from ..exceptions import ExceptionModule nx = ExceptionModule(E) def vertex_graph(entities): """ Given a set of entity objects generate a networkx.Graph that represents their vertex nodes. Parameters -------------- entities : list Objects with 'closed' and 'nodes' attributes Returns ------------- graph : networkx.Graph Graph where node indexes represent vertices closed : (n,) int Indexes of entities which are 'closed' """ graph = nx.Graph() closed = [] for index, entity in enumerate(entities): if entity.closed: closed.append(index) else: graph.add_edges_from(entity.nodes, entity_index=index) return graph, np.array(closed) def vertex_to_entity_path(vertex_path, graph, entities, vertices=None): """ Convert a path of vertex indices to a path of entity indices. Parameters ---------- vertex_path : (n,) int Ordered list of vertex indices representing a path graph : nx.Graph Vertex connectivity entities : (m,) list Entity objects vertices : (p, dimension) float Vertex points in space Returns ---------- entity_path : (q,) int Entity indices which make up vertex_path """ def edge_direction(a, b): """ Given two edges, figure out if the first needs to be reversed to keep the progression forward. [1,0] [1,2] -1 1 [1,0] [2,1] -1 -1 [0,1] [1,2] 1 1 [0,1] [2,1] 1 -1 Parameters ------------ a : (2,) int b : (2,) int Returns ------------ a_direction : int b_direction : int """ if a[0] == b[0]: return -1, 1 elif a[0] == b[1]: return -1, -1 elif a[1] == b[0]: return 1, 1 elif a[1] == b[1]: return 1, -1 else: msg = 'edges not connected!' msg += '\nvertex_path: {}'.format(vertex_path) msg += '\nentity_path: {}'.format(entity_path) msg += '\nentity[a]: {}'.format(entities[ea].points) msg += '\nentity[b]: {}'.format(entities[eb].points) constants.log.warning(msg) return None, None if vertices is None or vertices.shape[1] != 2: ccw_direction = 1 else: ccw_check = is_ccw(vertices[np.append(vertex_path, vertex_path[0])]) ccw_direction = (ccw_check * 2) - 1 # make sure vertex path is correct type vertex_path = np.asanyarray(vertex_path, dtype=np.int64) # we will be saving entity indexes entity_path = [] # loop through pairs of vertices for i in np.arange(len(vertex_path) + 1): # get two wrapped vertex positions vertex_path_pos = np.mod(np.arange(2) + i, len(vertex_path)) vertex_index = vertex_path[vertex_path_pos] entity_index = graph.get_edge_data(*vertex_index)['entity_index'] entity_path.append(entity_index) # remove duplicate entities and order CCW entity_path = grouping.unique_ordered(entity_path)[::ccw_direction] # check to make sure there is more than one entity if len(entity_path) == 1: # apply CCW reverse in place if necessary if ccw_direction < 0: index = entity_path[0] entities[index].reverse() return entity_path # traverse the entity path and reverse entities in place to # align with this path ordering round_trip = np.append(entity_path, entity_path[0]) round_trip = zip(round_trip[:-1], round_trip[1:]) for ea, eb in round_trip: da, db = edge_direction(entities[ea].end_points, entities[eb].end_points) if da is not None: entities[ea].reverse(direction=da) entities[eb].reverse(direction=db) entity_path = np.array(entity_path) return entity_path def closed_paths(entities, vertices): """ Paths are lists of entity indices. We first generate vertex paths using graph cycle algorithms, and then convert them to entity paths. This will also change the ordering of entity.points in place so a path may be traversed without having to reverse the entity. Parameters ------------- entities : (n,) entity objects Entity objects vertices : (m, dimension) float Vertex points in space Returns ------------- entity_paths : sequence of (n,) int Ordered traversals of entities """ # get a networkx graph of entities graph, closed = vertex_graph(entities) # add entities that are closed as single- entity paths entity_paths = np.reshape(closed, (-1, 1)).tolist() # look for cycles in the graph, or closed loops vertex_paths = np.array(nx.cycles.cycle_basis(graph)) # loop through every vertex cycle for vertex_path in vertex_paths: # a path has no length if it has fewer than 2 vertices if len(vertex_path) < 2: continue # convert vertex indices to entity indices entity_paths.append( vertex_to_entity_path(vertex_path, graph, entities, vertices)) entity_paths = np.array(entity_paths) return entity_paths def discretize_path(entities, vertices, path, scale=1.0): """ Turn a list of entity indices into a path of connected points. Parameters ----------- entities : (j,) entity objects Objects like 'Line', 'Arc', etc. vertices: (n, dimension) float Vertex points in space. path : (m,) int Indexes of entities scale : float Overall scale of drawing used for numeric tolerances in certain cases Returns ----------- discrete : (p, dimension) float Connected points in space that lie on the path and can be connected with line segments. """ # make sure vertices are numpy array vertices = np.asanyarray(vertices) path_len = len(path) if path_len == 0: raise ValueError('Cannot discretize empty path!') if path_len == 1: # case where we only have one entity discrete = np.asanyarray(entities[path[0]].discrete( vertices, scale=scale)) else: # run through path appending each entity discrete = [] for i, entity_id in enumerate(path): # the current (n, dimension) discrete curve of an entity current = entities[entity_id].discrete(vertices, scale=scale) # check if we are on the final entity if i >= (path_len - 1): # if we are on the last entity include the last point discrete.append(current) else: # slice off the last point so we don't get duplicate # points from the end of one entity and the start of another discrete.append(current[:-1]) # stack all curves to one nice (n, dimension) curve discrete = np.vstack(discrete) # make sure 2D curves are are counterclockwise if vertices.shape[1] == 2 and not is_ccw(discrete): # reversing will make array non c- contiguous discrete = np.ascontiguousarray(discrete[::-1]) return discrete class PathSample: def __init__(self, points): # make sure input array is numpy self._points = np.array(points) # find the direction of each segment self._vectors = np.diff(self._points, axis=0) # find the length of each segment self._norms = util.row_norm(self._vectors) # unit vectors for each segment nonzero = self._norms > constants.tol_path.zero self._unit_vec = self._vectors.copy() self._unit_vec[nonzero] /= self._norms[nonzero].reshape((-1, 1)) # total distance in the path self.length = self._norms.sum() # cumulative sum of section length # note that this is sorted self._cum_norm = np.cumsum(self._norms) def sample(self, distances): # return the indices in cum_norm that each sample would # need to be inserted at to maintain the sorted property positions = np.searchsorted(self._cum_norm, distances) positions = np.clip(positions, 0, len(self._unit_vec) - 1) offsets = np.append(0, self._cum_norm)[positions] # the distance past the reference vertex we need to travel projection = distances - offsets # find out which dirction we need to project direction = self._unit_vec[positions] # find out which vertex we're offset from origin = self._points[positions] # just the parametric equation for a line resampled = origin + (direction * projection.reshape((-1, 1))) return resampled def truncate(self, distance): """ Return a truncated version of the path. Only one vertex (at the endpoint) will be added. """ position = np.searchsorted(self._cum_norm, distance) offset = distance - self._cum_norm[position - 1] if offset < constants.tol_path.merge: truncated = self._points[:position + 1] else: vector = util.unitize(np.diff(self._points[np.arange(2) + position], axis=0).reshape(-1)) vector *= offset endpoint = self._points[position] + vector truncated = np.vstack((self._points[:position + 1], endpoint)) assert ( util.row_norm( np.diff( truncated, axis=0)).sum() - distance) < constants.tol_path.merge return truncated def resample_path(points, count=None, step=None, step_round=True): """ Given a path along (n,d) points, resample them such that the distance traversed along the path is constant in between each of the resampled points. Note that this can produce clipping at corners, as the original vertices are NOT guaranteed to be in the new, resampled path. ONLY ONE of count or step can be specified Result can be uniformly distributed (np.linspace) by specifying count Result can have a specific distance (np.arange) by specifying step Parameters ---------- points: (n, d) float Points in space count : int, Number of points to sample evenly (aka np.linspace) step : float Distance each step should take along the path (aka np.arange) Returns ---------- resampled : (j,d) float Points on the path """ points = np.array(points, dtype=np.float) # generate samples along the perimeter from kwarg count or step if (count is not None) and (step is not None): raise ValueError('Only step OR count can be specified') if (count is None) and (step is None): raise ValueError('Either step or count must be specified') sampler = PathSample(points) if step is not None and step_round: if step >= sampler.length: return points[[0, -1]] count = int(np.ceil(sampler.length / step)) if count is not None: samples = np.linspace(0, sampler.length, count) elif step is not None: samples = np.arange(0, sampler.length, step) resampled = sampler.sample(samples) check = util.row_norm(points[[0, -1]] - resampled[[0, -1]]) assert check[0] < constants.tol_path.merge if count is not None: assert check[1] < constants.tol_path.merge return resampled def split(self): """ Split a Path2D into multiple Path2D objects where each one has exactly one root curve. Parameters -------------- self : trimesh.path.Path2D Input geometry Returns ------------- split : list of trimesh.path.Path2D Original geometry as separate paths """ # avoid a circular import by referencing class of self Path2D = type(self) # save the results of the split to an array split = [] # get objects from cache to avoid a bajillion # cache checks inside the tight loop paths = self.paths discrete = self.discrete polygons_closed = self.polygons_closed enclosure_directed = self.enclosure_directed for root_index, root in enumerate(self.root): # get a list of the root curve's children connected = list(enclosure_directed[root].keys()) # add the root node to the list connected.append(root) # store new paths and entities new_paths = [] new_entities = [] for index in connected: path = paths[index] # add a path which is just sequential indexes new_paths.append(np.arange(len(path)) + len(new_entities)) # save the entity indexes new_entities.extend(path) # store the root index from the original drawing metadata = copy.deepcopy(self.metadata) metadata['split_2D'] = root_index # we made the root path the last index of connected new_root = np.array([len(new_paths) - 1]) # prevents the copying from nuking our cache with self._cache: # create the Path2D split.append(Path2D( entities=copy.deepcopy(self.entities[new_entities]), vertices=copy.deepcopy(self.vertices), metadata=metadata)) # add back expensive things to the cache split[-1]._cache.update( {'paths': new_paths, 'polygons_closed': polygons_closed[connected], 'discrete': discrete[connected], 'root': new_root}) # set the cache ID split[-1]._cache.id_set() return np.array(split)