1003 lines
30 KiB
Python
1003 lines
30 KiB
Python
|
"""
|
||
|
graph.py
|
||
|
-------------
|
||
|
|
||
|
Deal with graph operations. Primarily deal with graphs in (n, 2)
|
||
|
edge list form, and abstract the backend graph library being used.
|
||
|
|
||
|
Currently uses networkx or scipy.sparse.csgraph backend.
|
||
|
"""
|
||
|
|
||
|
import numpy as np
|
||
|
import collections
|
||
|
|
||
|
from . import util
|
||
|
from . import grouping
|
||
|
from . import exceptions
|
||
|
|
||
|
from .constants import log, tol
|
||
|
from .geometry import faces_to_edges
|
||
|
|
||
|
try:
|
||
|
from scipy.sparse import csgraph, coo_matrix
|
||
|
except BaseException as E:
|
||
|
# re-raise exception when used
|
||
|
csgraph = exceptions.ExceptionModule(E)
|
||
|
coo_matrix = exceptions.closure(E)
|
||
|
|
||
|
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)
|
||
|
|
||
|
|
||
|
def face_adjacency(faces=None,
|
||
|
mesh=None,
|
||
|
return_edges=False):
|
||
|
"""
|
||
|
Returns an (n, 2) list of face indices.
|
||
|
Each pair of faces in the list shares an edge, making them adjacent.
|
||
|
|
||
|
|
||
|
Parameters
|
||
|
-----------
|
||
|
faces : (n, 3) int, or None
|
||
|
Vertex indices representing triangles
|
||
|
mesh : Trimesh object
|
||
|
If passed will used cached edges
|
||
|
instead of generating from faces
|
||
|
return_edges : bool
|
||
|
Return the edges shared by adjacent faces
|
||
|
|
||
|
Returns
|
||
|
----------
|
||
|
adjacency : (m, 2) int
|
||
|
Indexes of faces that are adjacent
|
||
|
edges: (m, 2) int
|
||
|
Only returned if return_edges is True
|
||
|
Indexes of vertices which make up the
|
||
|
edges shared by the adjacent faces
|
||
|
|
||
|
Examples
|
||
|
----------
|
||
|
This is useful for lots of things such as finding
|
||
|
face- connected components:
|
||
|
>>> graph = nx.Graph()
|
||
|
>>> graph.add_edges_from(mesh.face_adjacency)
|
||
|
>>> groups = nx.connected_components(graph_connected)
|
||
|
"""
|
||
|
|
||
|
if mesh is None:
|
||
|
# first generate the list of edges for the current faces
|
||
|
# also return the index for which face the edge is from
|
||
|
edges, edges_face = faces_to_edges(faces, return_index=True)
|
||
|
# make sure edge rows are sorted
|
||
|
edges.sort(axis=1)
|
||
|
else:
|
||
|
# if passed a mesh, used the cached values
|
||
|
edges = mesh.edges_sorted
|
||
|
edges_face = mesh.edges_face
|
||
|
|
||
|
# this will return the indices for duplicate edges
|
||
|
# every edge appears twice in a well constructed mesh
|
||
|
# so for every row in edge_idx:
|
||
|
# edges[edge_idx[*][0]] == edges[edge_idx[*][1]]
|
||
|
# in this call to group rows we discard edges which
|
||
|
# don't occur twice
|
||
|
edge_groups = grouping.group_rows(edges, require_count=2)
|
||
|
|
||
|
if len(edge_groups) == 0:
|
||
|
log.warning('No adjacent faces detected! Did you merge vertices?')
|
||
|
|
||
|
# the pairs of all adjacent faces
|
||
|
# so for every row in face_idx, self.faces[face_idx[*][0]] and
|
||
|
# self.faces[face_idx[*][1]] will share an edge
|
||
|
adjacency = edges_face[edge_groups]
|
||
|
|
||
|
# degenerate faces may appear in adjacency as the same value
|
||
|
nondegenerate = adjacency[:, 0] != adjacency[:, 1]
|
||
|
adjacency = adjacency[nondegenerate]
|
||
|
|
||
|
# sort pairs in-place so we can search for indexes with ordered pairs
|
||
|
adjacency.sort(axis=1)
|
||
|
|
||
|
if return_edges:
|
||
|
adjacency_edges = edges[edge_groups[:, 0][nondegenerate]]
|
||
|
assert len(adjacency_edges) == len(adjacency)
|
||
|
return adjacency, adjacency_edges
|
||
|
return adjacency
|
||
|
|
||
|
|
||
|
def face_adjacency_unshared(mesh):
|
||
|
"""
|
||
|
Return the vertex index of the two vertices not in the shared
|
||
|
edge between two adjacent faces
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
mesh : Trimesh object
|
||
|
Input mesh
|
||
|
|
||
|
Returns
|
||
|
-----------
|
||
|
vid_unshared : (len(mesh.face_adjacency), 2) int
|
||
|
Indexes of mesh.vertices
|
||
|
for degenerate faces without exactly
|
||
|
one unshared vertex per face it will be -1
|
||
|
"""
|
||
|
|
||
|
# the non- shared vertex index is the same shape
|
||
|
# as face_adjacency holding vertex indices vs face indices
|
||
|
vid_unshared = np.zeros_like(mesh.face_adjacency,
|
||
|
dtype=np.int64) - 1
|
||
|
# get the shared edges between adjacent faces
|
||
|
edges = mesh.face_adjacency_edges
|
||
|
|
||
|
# loop through the two columns of face adjacency
|
||
|
for i, fid in enumerate(mesh.face_adjacency.T):
|
||
|
# faces from the current column of face adjacency
|
||
|
faces = mesh.faces[fid]
|
||
|
# should have one True per row of (3,)
|
||
|
# index of vertex not included in shared edge
|
||
|
unshared = np.logical_not(np.logical_or(
|
||
|
faces == edges[:, 0].reshape((-1, 1)),
|
||
|
faces == edges[:, 1].reshape((-1, 1))))
|
||
|
# each row should have exactly one uncontained verted
|
||
|
row_ok = unshared.sum(axis=1) == 1
|
||
|
# any degenerate row should be ignored
|
||
|
unshared[~row_ok, :] = False
|
||
|
# set the
|
||
|
vid_unshared[row_ok, i] = faces[unshared]
|
||
|
|
||
|
return vid_unshared
|
||
|
|
||
|
|
||
|
def face_adjacency_radius(mesh):
|
||
|
"""
|
||
|
Compute an approximate radius between adjacent faces.
|
||
|
|
||
|
Parameters
|
||
|
--------------
|
||
|
mesh : trimesh.Trimesh
|
||
|
|
||
|
Returns
|
||
|
-------------
|
||
|
radii : (len(self.face_adjacency),) float
|
||
|
Approximate radius between faces
|
||
|
Parallel faces will have a value of np.inf
|
||
|
span : (len(self.face_adjacency),) float
|
||
|
Perpendicular projection distance of two
|
||
|
unshared vertices onto the shared edge
|
||
|
"""
|
||
|
|
||
|
# solve for the radius of the adjacent faces
|
||
|
# distance
|
||
|
# R = ------------------
|
||
|
# 2 * sin(theta / 2)
|
||
|
nonzero = mesh.face_adjacency_angles > np.radians(.01)
|
||
|
denominator = np.abs(
|
||
|
2.0 * np.sin(mesh.face_adjacency_angles[nonzero] / 1.0))
|
||
|
|
||
|
# consider the distance between the non- shared vertices of the
|
||
|
# face adjacency pair as the key distance
|
||
|
point_pairs = mesh.vertices[mesh.face_adjacency_unshared]
|
||
|
vectors = np.diff(point_pairs,
|
||
|
axis=1).reshape((-1, 3))
|
||
|
|
||
|
# the vertex indices of the shared edge for the adjacency pairx
|
||
|
edges = mesh.face_adjacency_edges
|
||
|
# unit vector along shared the edge
|
||
|
edges_vec = util.unitize(np.diff(mesh.vertices[edges],
|
||
|
axis=1).reshape((-1, 3)))
|
||
|
|
||
|
# the vector of the perpendicular projection to the shared edge
|
||
|
perp = np.subtract(
|
||
|
vectors, (util.diagonal_dot(
|
||
|
vectors, edges_vec).reshape(
|
||
|
(-1, 1)) * edges_vec))
|
||
|
# the length of the perpendicular projection
|
||
|
span = util.row_norm(perp)
|
||
|
|
||
|
# complete the values for non- infinite radii
|
||
|
radii = np.ones(len(mesh.face_adjacency)) * np.inf
|
||
|
radii[nonzero] = span[nonzero] / denominator
|
||
|
|
||
|
return radii, span
|
||
|
|
||
|
|
||
|
def vertex_adjacency_graph(mesh):
|
||
|
"""
|
||
|
Returns a networkx graph representing the vertices and
|
||
|
their connections in the mesh.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
mesh : Trimesh object
|
||
|
|
||
|
Returns
|
||
|
---------
|
||
|
graph : networkx.Graph
|
||
|
Graph representing vertices and edges between
|
||
|
them where vertices are nodes and edges are edges
|
||
|
|
||
|
Examples
|
||
|
----------
|
||
|
This is useful for getting nearby vertices for a given vertex,
|
||
|
potentially for some simple smoothing techniques.
|
||
|
>>> graph = mesh.vertex_adjacency_graph
|
||
|
>>> graph.neighbors(0)
|
||
|
> [1, 3, 4]
|
||
|
"""
|
||
|
g = nx.Graph()
|
||
|
g.add_edges_from(mesh.edges_unique)
|
||
|
return g
|
||
|
|
||
|
|
||
|
def shared_edges(faces_a, faces_b):
|
||
|
"""
|
||
|
Given two sets of faces, find the edges which are in both sets.
|
||
|
|
||
|
Parameters
|
||
|
---------
|
||
|
faces_a : (n, 3) int
|
||
|
Array of faces
|
||
|
faces_b : (m, 3) int
|
||
|
Array of faces
|
||
|
|
||
|
Returns
|
||
|
---------
|
||
|
shared : (p, 2) int
|
||
|
Edges shared between faces
|
||
|
"""
|
||
|
e_a = np.sort(faces_to_edges(faces_a), axis=1)
|
||
|
e_b = np.sort(faces_to_edges(faces_b), axis=1)
|
||
|
shared = grouping.boolean_rows(
|
||
|
e_a, e_b, operation=np.intersect1d)
|
||
|
return shared
|
||
|
|
||
|
|
||
|
def facets(mesh, engine=None):
|
||
|
"""
|
||
|
Find the list of parallel adjacent faces.
|
||
|
|
||
|
Parameters
|
||
|
-----------
|
||
|
mesh : trimesh.Trimesh
|
||
|
engine : str
|
||
|
Which graph engine to use:
|
||
|
('scipy', 'networkx')
|
||
|
|
||
|
Returns
|
||
|
---------
|
||
|
facets : sequence of (n,) int
|
||
|
Groups of face indexes of
|
||
|
parallel adjacent faces.
|
||
|
"""
|
||
|
# what is the radius of a circle that passes through the perpendicular
|
||
|
# projection of the vector between the two non- shared vertices
|
||
|
# onto the shared edge, with the face normal from the two adjacent faces
|
||
|
radii = mesh.face_adjacency_radius
|
||
|
# what is the span perpendicular to the shared edge
|
||
|
span = mesh.face_adjacency_span
|
||
|
# a very arbitrary formula for declaring two adjacent faces
|
||
|
# parallel in a way that is hopefully (and anecdotally) robust
|
||
|
# to numeric error
|
||
|
# a common failure mode is two faces that are very narrow with a slight
|
||
|
# angle between them, so here we divide by the perpendicular span
|
||
|
# to penalize very narrow faces, and then square it just for fun
|
||
|
parallel = np.ones(len(radii), dtype=np.bool)
|
||
|
# if span is zero we know faces are small/parallel
|
||
|
nonzero = np.abs(span) > tol.zero
|
||
|
# faces with a radii/span ratio larger than a threshold pass
|
||
|
parallel[nonzero] = (radii[nonzero] /
|
||
|
span[nonzero]) ** 2 > tol.facet_threshold
|
||
|
|
||
|
# run connected components on the parallel faces to group them
|
||
|
components = connected_components(
|
||
|
mesh.face_adjacency[parallel],
|
||
|
nodes=np.arange(len(mesh.faces)),
|
||
|
min_len=2,
|
||
|
engine=engine)
|
||
|
|
||
|
return components
|
||
|
|
||
|
|
||
|
def split(mesh, only_watertight=True, adjacency=None, engine=None, **kwargs):
|
||
|
"""
|
||
|
Split a mesh into multiple meshes from face
|
||
|
connectivity.
|
||
|
|
||
|
If only_watertight is true it will only return
|
||
|
watertight meshes and will attempt to repair
|
||
|
single triangle or quad holes.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
mesh : trimesh.Trimesh
|
||
|
only_watertight: bool
|
||
|
Only return watertight components
|
||
|
adjacency : (n, 2) int
|
||
|
Face adjacency to override full mesh
|
||
|
engine : str or None
|
||
|
Which graph engine to use
|
||
|
|
||
|
Returns
|
||
|
----------
|
||
|
meshes : (m,) trimesh.Trimesh
|
||
|
Results of splitting
|
||
|
"""
|
||
|
if adjacency is None:
|
||
|
adjacency = mesh.face_adjacency
|
||
|
|
||
|
# if only watertight the shortest thing we can split has 3 triangles
|
||
|
if only_watertight:
|
||
|
min_len = 4
|
||
|
else:
|
||
|
min_len = 1
|
||
|
|
||
|
components = connected_components(
|
||
|
edges=adjacency,
|
||
|
nodes=np.arange(len(mesh.faces)),
|
||
|
min_len=min_len,
|
||
|
engine=engine)
|
||
|
meshes = mesh.submesh(
|
||
|
components, only_watertight=only_watertight, **kwargs)
|
||
|
return meshes
|
||
|
|
||
|
|
||
|
def connected_components(edges,
|
||
|
min_len=1,
|
||
|
nodes=None,
|
||
|
engine=None):
|
||
|
"""
|
||
|
Find groups of connected nodes from an edge list.
|
||
|
|
||
|
Parameters
|
||
|
-----------
|
||
|
edges : (n, 2) int
|
||
|
Edges between nodes
|
||
|
nodes : (m, ) int or None
|
||
|
List of nodes that exist
|
||
|
min_len : int
|
||
|
Minimum length of a component group to return
|
||
|
engine : str or None
|
||
|
Which graph engine to use (None for automatic):
|
||
|
(None, 'networkx', 'scipy')
|
||
|
|
||
|
|
||
|
Returns
|
||
|
-----------
|
||
|
components : (n,) sequence of (*,) int
|
||
|
Nodes which are connected
|
||
|
"""
|
||
|
def components_networkx():
|
||
|
"""
|
||
|
Find connected components using networkx
|
||
|
"""
|
||
|
graph = nx.from_edgelist(edges)
|
||
|
# make sure every face has a node, so single triangles
|
||
|
# aren't discarded (as they aren't adjacent to anything)
|
||
|
if min_len <= 1:
|
||
|
graph.add_nodes_from(nodes)
|
||
|
iterable = nx.connected_components(graph)
|
||
|
# newer versions of networkx return sets rather than lists
|
||
|
components = np.array(
|
||
|
[np.array(list(i), dtype=np.int64)
|
||
|
for i in iterable if len(i) >= min_len])
|
||
|
return components
|
||
|
|
||
|
def components_csgraph():
|
||
|
"""
|
||
|
Find connected components using scipy.sparse.csgraph
|
||
|
"""
|
||
|
# label each node
|
||
|
labels = connected_component_labels(edges,
|
||
|
node_count=node_count)
|
||
|
|
||
|
# we have to remove results that contain nodes outside
|
||
|
# of the specified node set and reindex
|
||
|
contained = np.zeros(node_count, dtype=np.bool)
|
||
|
contained[nodes] = True
|
||
|
index = np.arange(node_count, dtype=np.int64)[contained]
|
||
|
|
||
|
components = grouping.group(labels[contained], min_len=min_len)
|
||
|
components = np.array([index[c] for c in components])
|
||
|
|
||
|
return components
|
||
|
|
||
|
# check input edges
|
||
|
edges = np.asanyarray(edges, dtype=np.int64)
|
||
|
# if no nodes were specified just use unique
|
||
|
if nodes is None:
|
||
|
nodes = np.unique(edges)
|
||
|
|
||
|
# exit early if we have no nodes
|
||
|
if len(nodes) == 0:
|
||
|
return np.array([])
|
||
|
elif len(edges) == 0:
|
||
|
if min_len <= 1:
|
||
|
return np.reshape(nodes, (-1, 1))
|
||
|
else:
|
||
|
return np.array([])
|
||
|
|
||
|
if not util.is_shape(edges, (-1, 2)):
|
||
|
raise ValueError('edges must be (n, 2)!')
|
||
|
|
||
|
# find the maximum index referenced in either nodes or edges
|
||
|
counts = [0]
|
||
|
if len(edges) > 0:
|
||
|
counts.append(edges.max())
|
||
|
if len(nodes) > 0:
|
||
|
counts.append(nodes.max())
|
||
|
node_count = np.max(counts) + 1
|
||
|
|
||
|
# remove edges that don't have both nodes in the node set
|
||
|
mask = np.zeros(node_count, dtype=np.bool)
|
||
|
mask[nodes] = True
|
||
|
edges_ok = mask[edges].all(axis=1)
|
||
|
edges = edges[edges_ok]
|
||
|
|
||
|
# networkx is pure python and is usually 5-10x slower than scipy
|
||
|
engines = collections.OrderedDict((
|
||
|
('scipy', components_csgraph),
|
||
|
('networkx', components_networkx)))
|
||
|
|
||
|
# if a graph engine has explicitly been requested use it
|
||
|
if engine in engines:
|
||
|
return engines[engine]()
|
||
|
|
||
|
# otherwise, go through our ordered list of graph engines
|
||
|
# until we get to one that has actually been installed
|
||
|
for function in engines.values():
|
||
|
try:
|
||
|
return function()
|
||
|
# will be raised if the library didn't import correctly above
|
||
|
except NameError:
|
||
|
continue
|
||
|
raise ImportError('No connected component engines available!')
|
||
|
|
||
|
|
||
|
def connected_component_labels(edges, node_count=None):
|
||
|
"""
|
||
|
Label graph nodes from an edge list, using scipy.sparse.csgraph
|
||
|
|
||
|
Parameters
|
||
|
-----------
|
||
|
edges : (n, 2) int
|
||
|
Edges of a graph
|
||
|
node_count : int, or None
|
||
|
The largest node in the graph.
|
||
|
|
||
|
Returns
|
||
|
----------
|
||
|
labels : (node_count,) int
|
||
|
Component labels for each node
|
||
|
"""
|
||
|
matrix = edges_to_coo(edges, node_count)
|
||
|
body_count, labels = csgraph.connected_components(
|
||
|
matrix, directed=False)
|
||
|
|
||
|
if node_count is not None:
|
||
|
assert len(labels) == node_count
|
||
|
|
||
|
return labels
|
||
|
|
||
|
|
||
|
def split_traversal(traversal,
|
||
|
edges,
|
||
|
edges_hash=None):
|
||
|
"""
|
||
|
Given a traversal as a list of nodes, split the traversal
|
||
|
if a sequential index pair is not in the given edges.
|
||
|
|
||
|
Parameters
|
||
|
--------------
|
||
|
edges : (n, 2) int
|
||
|
Graph edge indexes
|
||
|
traversal : (m,) int
|
||
|
Traversal through edges
|
||
|
edge_hash : (n,)
|
||
|
Edges sorted on axis=1 and
|
||
|
passed to grouping.hashable_rows
|
||
|
|
||
|
Returns
|
||
|
---------------
|
||
|
split : sequence of (p,) int
|
||
|
"""
|
||
|
traversal = np.asanyarray(traversal,
|
||
|
dtype=np.int64)
|
||
|
|
||
|
# hash edge rows for contains checks
|
||
|
if edges_hash is None:
|
||
|
edges_hash = grouping.hashable_rows(
|
||
|
np.sort(edges, axis=1))
|
||
|
|
||
|
# turn the (n,) traversal into (n-1, 2) edges
|
||
|
trav_edge = np.column_stack((traversal[:-1],
|
||
|
traversal[1:]))
|
||
|
# hash each edge so we can compare to edge set
|
||
|
trav_hash = grouping.hashable_rows(
|
||
|
np.sort(trav_edge, axis=1))
|
||
|
# check if each edge is contained in edge set
|
||
|
contained = np.in1d(trav_hash, edges_hash)
|
||
|
|
||
|
# exit early if every edge of traversal exists
|
||
|
if contained.all():
|
||
|
# just reshape one traversal
|
||
|
split = [traversal]
|
||
|
else:
|
||
|
# find contiguous groups of contained edges
|
||
|
blocks = grouping.blocks(contained,
|
||
|
min_len=1,
|
||
|
only_nonzero=True)
|
||
|
|
||
|
# turn edges back in to sequence of traversals
|
||
|
split = [np.append(trav_edge[b][:, 0],
|
||
|
trav_edge[b[-1]][1])
|
||
|
for b in blocks]
|
||
|
|
||
|
# close traversals if necessary
|
||
|
for i, t in enumerate(split):
|
||
|
# make sure elements of sequence are numpy arrays
|
||
|
split[i] = np.asanyarray(split[i], dtype=np.int64)
|
||
|
# don't close if its a single edge
|
||
|
if len(t) <= 2:
|
||
|
continue
|
||
|
# make sure it's not already closed
|
||
|
edge = np.sort([t[0], t[-1]])
|
||
|
if edge.ptp() == 0:
|
||
|
continue
|
||
|
close = grouping.hashable_rows(edge.reshape((1, 2)))[0]
|
||
|
# if we need the edge add it
|
||
|
if close in edges_hash:
|
||
|
split[i] = np.append(t, t[0]).astype(np.int64)
|
||
|
result = np.array(split)
|
||
|
|
||
|
return result
|
||
|
|
||
|
|
||
|
def fill_traversals(traversals, edges, edges_hash=None):
|
||
|
"""
|
||
|
Convert a traversal of a list of edges into a sequence of
|
||
|
traversals where every pair of consecutive node indexes
|
||
|
is an edge in a passed edge list
|
||
|
|
||
|
Parameters
|
||
|
-------------
|
||
|
traversals : sequence of (m,) int
|
||
|
Node indexes of traversals of a graph
|
||
|
edges : (n, 2) int
|
||
|
Pairs of connected node indexes
|
||
|
edges_hash : None, or (n,) int
|
||
|
Edges sorted along axis 1 then hashed
|
||
|
using grouping.hashable_rows
|
||
|
|
||
|
Returns
|
||
|
--------------
|
||
|
splits : sequence of (p,) int
|
||
|
Node indexes of connected traversals
|
||
|
"""
|
||
|
# make sure edges are correct type
|
||
|
edges = np.asanyarray(edges, dtype=np.int64)
|
||
|
# make sure edges are sorted
|
||
|
edges.sort(axis=1)
|
||
|
|
||
|
# if there are no traversals just return edges
|
||
|
if len(traversals) == 0:
|
||
|
return edges.copy()
|
||
|
|
||
|
# hash edges for contains checks
|
||
|
if edges_hash is None:
|
||
|
edges_hash = grouping.hashable_rows(edges)
|
||
|
|
||
|
splits = []
|
||
|
for nodes in traversals:
|
||
|
# split traversals to remove edges
|
||
|
# that don't actually exist
|
||
|
splits.extend(split_traversal(
|
||
|
traversal=nodes,
|
||
|
edges=edges,
|
||
|
edges_hash=edges_hash))
|
||
|
# turn the split traversals back into (n, 2) edges
|
||
|
included = util.vstack_empty([np.column_stack((i[:-1], i[1:]))
|
||
|
for i in splits])
|
||
|
if len(included) > 0:
|
||
|
# sort included edges in place
|
||
|
included.sort(axis=1)
|
||
|
# make sure any edges not included in split traversals
|
||
|
# are just added as a length 2 traversal
|
||
|
splits.extend(grouping.boolean_rows(
|
||
|
edges,
|
||
|
included,
|
||
|
operation=np.setdiff1d))
|
||
|
else:
|
||
|
# no edges were included, so our filled traversal
|
||
|
# is just the original edges copied over
|
||
|
splits = edges.copy()
|
||
|
|
||
|
return splits
|
||
|
|
||
|
|
||
|
def traversals(edges, mode='bfs'):
|
||
|
"""
|
||
|
Given an edge list generate a sequence of ordered depth
|
||
|
first search traversals using scipy.csgraph routines.
|
||
|
|
||
|
Parameters
|
||
|
------------
|
||
|
edges : (n, 2) int
|
||
|
Undirected edges of a graph
|
||
|
mode : str
|
||
|
Traversal type, 'bfs' or 'dfs'
|
||
|
|
||
|
Returns
|
||
|
-----------
|
||
|
traversals : (m,) sequence of (p,) int
|
||
|
Ordered DFS or BFS traversals of the graph.
|
||
|
"""
|
||
|
edges = np.array(edges, dtype=np.int64)
|
||
|
if len(edges) == 0:
|
||
|
return []
|
||
|
elif not util.is_shape(edges, (-1, 2)):
|
||
|
raise ValueError('edges are not (n, 2)!')
|
||
|
|
||
|
# pick the traversal method
|
||
|
mode = str(mode).lower().strip()
|
||
|
if mode == 'bfs':
|
||
|
func = csgraph.breadth_first_order
|
||
|
elif mode == 'dfs':
|
||
|
func = csgraph.depth_first_order
|
||
|
else:
|
||
|
raise ValueError('traversal mode must be either dfs or bfs')
|
||
|
|
||
|
# make sure edges are sorted so we can query
|
||
|
# an ordered pair later
|
||
|
edges.sort(axis=1)
|
||
|
# set of nodes to make sure we get every node
|
||
|
nodes = set(edges.reshape(-1))
|
||
|
# coo_matrix for csgraph routines
|
||
|
graph = edges_to_coo(edges)
|
||
|
|
||
|
# we're going to make a sequence of traversals
|
||
|
traversals = []
|
||
|
|
||
|
while len(nodes) > 0:
|
||
|
# starting at any node
|
||
|
start = nodes.pop()
|
||
|
# get an (n,) ordered traversal
|
||
|
ordered = func(graph,
|
||
|
i_start=start,
|
||
|
return_predecessors=False,
|
||
|
directed=False).astype(np.int64)
|
||
|
|
||
|
traversals.append(ordered)
|
||
|
# remove the nodes we've consumed
|
||
|
nodes.difference_update(ordered)
|
||
|
|
||
|
return traversals
|
||
|
|
||
|
|
||
|
def edges_to_coo(edges, count=None, data=None):
|
||
|
"""
|
||
|
Given an edge list, return a boolean scipy.sparse.coo_matrix
|
||
|
representing the edges in matrix form.
|
||
|
|
||
|
Parameters
|
||
|
------------
|
||
|
edges : (n, 2) int
|
||
|
Edges of a graph
|
||
|
count : int
|
||
|
The total number of nodes in the graph
|
||
|
if None: count = edges.max() + 1
|
||
|
data : (n,) any
|
||
|
Assign data to each edge, if None will
|
||
|
be bool True for each specified edge
|
||
|
|
||
|
Returns
|
||
|
------------
|
||
|
matrix: (count, count) scipy.sparse.coo_matrix
|
||
|
Sparse COO
|
||
|
"""
|
||
|
edges = np.asanyarray(edges, dtype=np.int64)
|
||
|
if not (len(edges) == 0 or
|
||
|
util.is_shape(edges, (-1, 2))):
|
||
|
raise ValueError('edges must be (n, 2)!')
|
||
|
|
||
|
# if count isn't specified just set it to largest
|
||
|
# value referenced in edges
|
||
|
if count is None:
|
||
|
count = edges.max() + 1
|
||
|
count = int(count)
|
||
|
|
||
|
# if no data is specified set every specified edge
|
||
|
# to True
|
||
|
if data is None:
|
||
|
data = np.ones(len(edges), dtype=np.bool)
|
||
|
|
||
|
matrix = coo_matrix((data, edges.T),
|
||
|
dtype=data.dtype,
|
||
|
shape=(count, count))
|
||
|
return matrix
|
||
|
|
||
|
|
||
|
def neighbors(edges, max_index=None, directed=False):
|
||
|
"""
|
||
|
Find the neighbors for each node in an edgelist graph.
|
||
|
|
||
|
TODO : re-write this with sparse matrix operations
|
||
|
|
||
|
Parameters
|
||
|
------------
|
||
|
edges : (n, 2) int
|
||
|
Connected nodes
|
||
|
directed : bool
|
||
|
If True, only connect edges in one direction
|
||
|
|
||
|
Returns
|
||
|
---------
|
||
|
neighbors : sequence
|
||
|
Vertex index corresponds to set of other vertex indices
|
||
|
"""
|
||
|
neighbors = collections.defaultdict(set)
|
||
|
if directed:
|
||
|
[neighbors[edge[0]].add(edge[1])
|
||
|
for edge in edges]
|
||
|
else:
|
||
|
[(neighbors[edge[0]].add(edge[1]),
|
||
|
neighbors[edge[1]].add(edge[0]))
|
||
|
for edge in edges]
|
||
|
|
||
|
if max_index is None:
|
||
|
max_index = edges.max() + 1
|
||
|
array = [list(neighbors[i]) for i in range(max_index)]
|
||
|
|
||
|
return array
|
||
|
|
||
|
|
||
|
def smoothed(mesh, angle=None, facet_minarea=15):
|
||
|
"""
|
||
|
Return a non- watertight version of the mesh which
|
||
|
will render nicely with smooth shading by
|
||
|
disconnecting faces at sharp angles to each other.
|
||
|
|
||
|
Parameters
|
||
|
-----------
|
||
|
mesh : trimesh.Trimesh
|
||
|
Source geometry
|
||
|
angle : float or None
|
||
|
Angle in radians face pairs with angles
|
||
|
smaller than this will appear smoothed
|
||
|
facet_minarea : float or None
|
||
|
Minimum area fraction to consider
|
||
|
IE for `facets_minarea=25` only facets larger
|
||
|
than `mesh.area / 25` will be considered.
|
||
|
|
||
|
Returns
|
||
|
---------
|
||
|
smooth : trimesh.Trimesh
|
||
|
Geometry with disconnected face patches
|
||
|
"""
|
||
|
if angle is None:
|
||
|
angle = np.radians(30)
|
||
|
|
||
|
# if the mesh has no adjacent faces return a copy
|
||
|
if len(mesh.face_adjacency) == 0:
|
||
|
return mesh.copy()
|
||
|
|
||
|
# face pairs below angle threshold
|
||
|
angle_ok = mesh.face_adjacency_angles <= angle
|
||
|
# subset of face adjacency
|
||
|
adjacency = mesh.face_adjacency[angle_ok]
|
||
|
|
||
|
# coplanar groups of faces
|
||
|
facets = []
|
||
|
nodes = None
|
||
|
# collect coplanar regions for smoothing
|
||
|
if facet_minarea is not None:
|
||
|
areas = mesh.area_faces
|
||
|
min_area = mesh.area / facet_minarea
|
||
|
try:
|
||
|
# we can survive not knowing facets
|
||
|
# exclude facets with few faces
|
||
|
facets = [f for f in mesh.facets
|
||
|
if areas[f].sum() > min_area]
|
||
|
if len(facets) > 0:
|
||
|
# mask for removing adjacency pairs where
|
||
|
# one of the faces is contained in a facet
|
||
|
mask = np.ones(len(mesh.faces),
|
||
|
dtype=np.bool)
|
||
|
mask[np.hstack(facets)] = False
|
||
|
# apply the mask to adjacency
|
||
|
adjacency = adjacency[
|
||
|
mask[adjacency].all(axis=1)]
|
||
|
# nodes are no longer every faces
|
||
|
nodes = np.unique(adjacency)
|
||
|
except BaseException:
|
||
|
log.warning('failed to calculate facets',
|
||
|
exc_info=True)
|
||
|
# run connected components on facet adjacency
|
||
|
components = connected_components(
|
||
|
adjacency,
|
||
|
min_len=1,
|
||
|
nodes=nodes).tolist()
|
||
|
|
||
|
# add back coplanar groups if any exist
|
||
|
if len(facets) > 0:
|
||
|
components.extend(facets)
|
||
|
|
||
|
if len(components) == 0:
|
||
|
# if no components for some reason
|
||
|
# just return a copy of the original mesh
|
||
|
return mesh.copy()
|
||
|
|
||
|
# add back any faces that were missed
|
||
|
unique = np.unique(np.hstack(components))
|
||
|
if len(unique) != len(mesh.faces):
|
||
|
# things like single loose faces
|
||
|
# or groups below facet_minlen
|
||
|
broke = np.setdiff1d(
|
||
|
np.arange(len(mesh.faces)), unique)
|
||
|
components.extend(broke.reshape((-1, 1)))
|
||
|
|
||
|
# get a submesh as a single appended Trimesh
|
||
|
smooth = mesh.submesh(components,
|
||
|
only_watertight=False,
|
||
|
append=True)
|
||
|
# store face indices from original mesh
|
||
|
smooth.metadata['original_components'] = components
|
||
|
# smoothed should have exactly the same number of faces
|
||
|
if len(smooth.faces) != len(mesh.faces):
|
||
|
log.warning('face count in smooth wrong!')
|
||
|
return smooth
|
||
|
|
||
|
|
||
|
def is_watertight(edges, edges_sorted=None):
|
||
|
"""
|
||
|
Parameters
|
||
|
-----------
|
||
|
edges : (n, 2) int
|
||
|
List of vertex indices
|
||
|
edges_sorted : (n, 2) int
|
||
|
Pass vertex indices sorted on axis 1 as a speedup
|
||
|
|
||
|
Returns
|
||
|
---------
|
||
|
watertight : boolean
|
||
|
Whether every edge is shared by an even
|
||
|
number of faces
|
||
|
winding : boolean
|
||
|
Whether every shared edge is reversed
|
||
|
"""
|
||
|
# passing edges_sorted is a speedup only
|
||
|
if edges_sorted is None:
|
||
|
edges_sorted = np.sort(edges, axis=1)
|
||
|
|
||
|
# group sorted edges
|
||
|
groups = grouping.group_rows(
|
||
|
edges_sorted, require_count=2)
|
||
|
watertight = bool((len(groups) * 2) == len(edges))
|
||
|
|
||
|
# are opposing edges reversed
|
||
|
opposing = edges[groups].reshape((-1, 4))[:, 1:3].T
|
||
|
# wrap the weird numpy bool
|
||
|
winding = bool(np.equal(*opposing).all())
|
||
|
|
||
|
return watertight, winding
|
||
|
|
||
|
|
||
|
def graph_to_svg(graph):
|
||
|
"""
|
||
|
Turn a networkx graph into an SVG string
|
||
|
using graphviz `dot`.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
graph: networkx graph
|
||
|
|
||
|
Returns
|
||
|
---------
|
||
|
svg: string, pictoral layout in SVG format
|
||
|
"""
|
||
|
|
||
|
import tempfile
|
||
|
import subprocess
|
||
|
with tempfile.NamedTemporaryFile() as dot_file:
|
||
|
nx.drawing.nx_agraph.write_dot(graph, dot_file.name)
|
||
|
svg = subprocess.check_output(['dot', dot_file.name, '-Tsvg'])
|
||
|
return svg
|
||
|
|
||
|
|
||
|
def multigraph_paths(G, source, cutoff=None):
|
||
|
"""
|
||
|
For a networkx MultiDiGraph, find all paths from a source node
|
||
|
to leaf nodes. This function returns edge instance numbers
|
||
|
in addition to nodes, unlike networkx.all_simple_paths.
|
||
|
|
||
|
Parameters
|
||
|
---------------
|
||
|
G : networkx.MultiDiGraph
|
||
|
Graph to evaluate
|
||
|
source : hashable
|
||
|
Node to start traversal at
|
||
|
cutoff : int
|
||
|
Number of nodes to visit
|
||
|
If None will visit all nodes
|
||
|
|
||
|
Returns
|
||
|
----------
|
||
|
traversals : (n,) list of [(node, edge instance index), ] paths
|
||
|
Traversals of the multigraph
|
||
|
"""
|
||
|
if cutoff is None:
|
||
|
cutoff = (len(G.edges()) * len(G.nodes())) + 1
|
||
|
|
||
|
# the path starts at the node specified
|
||
|
current = [(source, 0)]
|
||
|
# traversals we need to go back and do
|
||
|
queue = []
|
||
|
# completed paths
|
||
|
traversals = []
|
||
|
|
||
|
for i in range(cutoff):
|
||
|
# paths are stored as (node, instance) so
|
||
|
# get the node of the last place visited
|
||
|
current_node = current[-1][0]
|
||
|
# get all the children of the current node
|
||
|
child = G[current_node]
|
||
|
|
||
|
if len(child) == 0:
|
||
|
# we have no children, so we are at the end of this path
|
||
|
# save the path as a completed traversal
|
||
|
traversals.append(current)
|
||
|
# if there is nothing on the queue, we are done
|
||
|
if len(queue) == 0:
|
||
|
break
|
||
|
# otherwise continue traversing with the next path
|
||
|
# on the queue
|
||
|
current = queue.pop()
|
||
|
else:
|
||
|
# oh no, we have multiple edges from current -> child
|
||
|
start = True
|
||
|
# iterate through child nodes and edge instances
|
||
|
for node in child.keys():
|
||
|
for instance in child[node].keys():
|
||
|
if start:
|
||
|
# if this is the first edge, keep it on the
|
||
|
# current traversal and save the others for later
|
||
|
current.append((node, instance))
|
||
|
start = False
|
||
|
else:
|
||
|
# this child has multiple instances
|
||
|
# so we will need to traverse them multiple times
|
||
|
# we appended a node to current, so only take the
|
||
|
# first n-1 visits
|
||
|
queue.append(current[:-1] + [(node, instance)])
|
||
|
return traversals
|
||
|
|
||
|
|
||
|
def multigraph_collect(G, traversal, attrib=None):
|
||
|
"""
|
||
|
Given a MultiDiGraph traversal, collect attributes along it.
|
||
|
|
||
|
Parameters
|
||
|
-------------
|
||
|
G: networkx.MultiDiGraph
|
||
|
traversal: (n) list of (node, instance) tuples
|
||
|
attrib: dict key, name to collect. If None, will return all
|
||
|
|
||
|
Returns
|
||
|
-------------
|
||
|
collected: (len(traversal) - 1) list of attributes
|
||
|
"""
|
||
|
|
||
|
collected = []
|
||
|
for u, v in util.pairwise(traversal):
|
||
|
attribs = G[u[0]][v[0]][v[1]]
|
||
|
if attrib is None:
|
||
|
collected.append(attribs)
|
||
|
else:
|
||
|
collected.append(attribs[attrib])
|
||
|
return collected
|