Skip to content
This repository was archived by the owner on Feb 3, 2023. It is now read-only.

Commit de49d87

Browse files
authored
Fix mesh_xsection (#4)
* Fix mesh_xsection * fix lint * Fix bug in mesh_xsection that manifested with large meshes * fix lint * failing test for the case of non-watertight meshes * fix the multiple open paths bug * fix lint * bump version * Allow polylines to convert to Lines with a color specified
1 parent 9acf81a commit de49d87

File tree

5 files changed

+332
-144
lines changed

5 files changed

+332
-144
lines changed

Rakefile

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
$style_config_version = '1.0.1'
1+
$style_config_version = '2.1.0'
22

33
desc "Install style config"
44
task :install_style_config do

blmath/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = '1.1.0'
1+
__version__ = '1.1.1'

blmath/geometry/primitives/plane.py

+133-140
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ def __init__(self, point_on_plane, unit_normal):
1919
self._r0 = point_on_plane
2020
self._n = unit_normal
2121

22+
def __repr__(self):
23+
return "<Plane of {} through {}>".format(self.normal, self.reference_point)
24+
2225
@classmethod
2326
def from_points(cls, p1, p2, p3):
2427
'''
@@ -206,7 +209,43 @@ def polyline_xsection(self, polyline):
206209
#assert(np.all(self.distance(intersection_points) < 1e-10))
207210
return intersection_points
208211

212+
def line_xsection(self, pt, ray):
213+
pt = np.asarray(pt).ravel()
214+
ray = np.asarray(ray).ravel()
215+
assert len(pt) == 3
216+
assert len(ray) == 3
217+
denom = np.dot(ray, self.normal)
218+
if denom == 0:
219+
return None # parallel, either coplanar or non-intersecting
220+
p = np.dot(self.reference_point - pt, self.normal) / denom
221+
return p * ray + pt
222+
223+
def line_segment_xsection(self, a, b):
224+
a = np.asarray(a).ravel()
225+
b = np.asarray(b).ravel()
226+
assert len(a) == 3
227+
assert len(b) == 3
228+
229+
pt = self.line_xsection(a, b-a)
230+
if pt is not None:
231+
if np.any(pt < np.min((a, b), axis=0)) or np.any(pt > np.max((a, b), axis=0)):
232+
return None
233+
return pt
234+
209235
def mesh_xsection(self, m, neighborhood=None):
236+
'''
237+
Backwards compatible.
238+
Returns one polyline that may connect supposedly disconnected components.
239+
Returns an empty Polyline if there's no intersection.
240+
'''
241+
from blmath.geometry import Polyline
242+
243+
components = self.mesh_xsections(m, neighborhood)
244+
if len(components) == 0:
245+
return Polyline(None)
246+
return Polyline(np.vstack([x.v for x in components]), closed=True)
247+
248+
def mesh_xsections(self, m, neighborhood=None):
210249
'''
211250
Takes a cross section of planar point cloud with a Mesh object.
212251
Ignore those points which intersect at a vertex - the probability of
@@ -221,157 +260,111 @@ def mesh_xsection(self, m, neighborhood=None):
221260
- neigbhorhood:
222261
M x 3 np.array
223262
224-
Returns a Polyline.
225-
226-
TODO Return `None` instead of an empty polyline to signal no
227-
intersection.
228-
263+
Returns a list of Polylines.
229264
'''
265+
import operator
266+
import scipy.sparse as sp
230267
from blmath.geometry import Polyline
231268

232-
# Step 1:
233-
# Select those faces that intersect the plane, fs. Also construct
234-
# the signed distances (fs_dists) and normalized signed distances
235-
# (fs_norm_dists) for each such face.
269+
# 1: Select those faces that intersect the plane, fs
236270
sgn_dists = self.signed_distance(m.v)
237271
which_fs = np.abs(np.sign(sgn_dists)[m.f].sum(axis=1)) != 3
238272
fs = m.f[which_fs]
239-
fs_dists = sgn_dists[fs]
240-
fs_norm_dists = np.sign(fs_dists)
241-
242-
# Step 2:
243-
# Build a length 3 array of edges es. Each es[i] is an np.array
244-
# edge_pts of shape (fs.shape[0], 3). Each vector edge_pts[i, :]
245-
# in edge_pts is an interesection of the plane with the
246-
# fs[i], or [np.nan, np.nan, np.nan].
247-
es = []
248-
249-
import itertools
250-
for i, j in itertools.combinations([0, 1, 2], 2):
251-
vi = m.v[fs[:, i]]
252-
vj = m.v[fs[:, j]]
253-
254-
vi_dist = np.absolute(fs_dists[:, i])
255-
vj_dist = np.absolute(fs_dists[:, j])
256-
257-
vi_norm_dist = fs_norm_dists[:, i]
258-
vj_norm_dist = fs_norm_dists[:, j]
259-
260-
# use broadcasting to bulk traverse the edges
261-
t = vi_dist/(vi_dist + vj_dist)
262-
t = t[:, np.newaxis]
263-
264-
edge_pts = t * vj + (1 - t) * vi
265-
266-
# flag interior edge points that have the same sign with [nan, nan, nan].
267-
# note also that sum(trash.shape[0] for all i, j) == fs.shape[0], which holds.
268-
trash = np.nonzero(vi_norm_dist * vj_norm_dist == +1)[0]
269-
edge_pts[trash, :] = np.nan
270-
271-
es.append(edge_pts)
272-
273-
if any([edge.shape[0] == 0 for edge in es]):
274-
return Polyline(None)
275273

276-
# Step 3:
277-
# Build and return the verts v and edges e. Dump trash.
278-
hstacked = np.hstack(es)
279-
trash = np.isnan(hstacked)
280-
281-
cleaned = hstacked[np.logical_not(trash)].reshape(fs.shape[0], 6)
282-
v1s, v2s = np.hsplit(cleaned, 2)
283-
284-
v = np.empty((2 * v1s.shape[0], 3), dtype=v1s.dtype)
285-
v[0::2] = v1s
286-
v[1::2] = v2s
287-
288-
if neighborhood is None:
289-
return Polyline(v, closed=True)
290-
291-
# FIXME This e is incorrect.
292-
# Contains e.g.
293-
# [0, 1], [2, 3], [4, 5], ...
294-
# But should contain
295-
# [0, 1], [1, 2], [2, 3], ...
296-
# Leaving in place since the code below may depend on it.
297-
e = np.array([[i, i + 1] for i in xrange(0, v.shape[0], 2)])
298-
299-
# Step 4 (optional - only if 'neighborhood' is provided):
300-
# Build and return the ordered vertices cmp_v, and the
301-
# edges cmp_e. Get connected components, use a KDTree
302-
# to select the one with minimal distance to 'component'.
303-
# Return the cmp_v and (re-indexed) edge mapping cmp_e.
274+
if len(fs) == 0:
275+
return [] # Nothing intersects
276+
277+
# 2: Find the edges where each of those faces actually cross the plane
278+
def edge_from_face(f):
279+
face_verts = [
280+
[m.v[f[0]], m.v[f[1]]],
281+
[m.v[f[1]], m.v[f[2]]],
282+
[m.v[f[2]], m.v[f[0]]],
283+
]
284+
e = [self.line_segment_xsection(a, b) for a, b in face_verts]
285+
e = [x for x in e if x is not None]
286+
return e
287+
edges = np.vstack([np.hstack(edge_from_face(f)) for f in fs])
288+
289+
# 3: Find the set of unique vertices in `edges`
290+
v1s, v2s = np.hsplit(edges, 2)
291+
verts = edges.reshape((-1, 3))
292+
verts = np.vstack(sorted(verts, key=operator.itemgetter(0, 1, 2)))
293+
eps = 1e-15 # the floating point calculation of the intersection locations is not _quite_ exact
294+
verts = verts[list(np.sqrt(np.sum(np.diff(verts, axis=0) ** 2, axis=1)) > eps) + [True]]
295+
# the True at the end there is because np.diff returns pairwise differences; one less element than the original array
296+
297+
# 4: Build the edge adjacency matrix
298+
E = sp.dok_matrix((verts.shape[0], verts.shape[0]), dtype=np.bool)
299+
def indexof(v, in_this):
300+
return np.nonzero(np.all(np.abs(in_this - v) < eps, axis=1))[0]
301+
for ii, v in enumerate(verts):
302+
for other_v in list(v2s[indexof(v, v1s)]) + list(v1s[indexof(v, v2s)]):
303+
neighbors = indexof(other_v, verts)
304+
E[ii, neighbors] = True
305+
E[neighbors, ii] = True
306+
307+
def eulerPath(E):
308+
# Based on code from Przemek Drochomirecki, Krakow, 5 Nov 2006
309+
# http://code.activestate.com/recipes/498243-finding-eulerian-path-in-undirected-graph/
310+
# Under PSF License
311+
# NB: MUTATES graph
312+
if len(E.nonzero()[0]) == 0:
313+
return None
314+
# counting the number of vertices with odd degree
315+
odd = list(np.nonzero(np.bitwise_and(np.sum(E, axis=0), 1))[1])
316+
odd.append(np.nonzero(E)[0][0])
317+
# This check is appropriate if there is a single connected component.
318+
# Since we're willing to take away one connected component per call,
319+
# we skip this check.
320+
# if len(odd) > 3:
321+
# return None
322+
stack = [odd[0]]
323+
path = []
324+
# main algorithm
325+
while stack:
326+
v = stack[-1]
327+
nonzero = np.nonzero(E)
328+
nbrs = nonzero[1][nonzero[0] == v]
329+
if len(nbrs) > 0:
330+
u = nbrs[0]
331+
stack.append(u)
332+
# deleting edge u-v
333+
E[u, v] = False
334+
E[v, u] = False
335+
else:
336+
path.append(stack.pop())
337+
return path
338+
339+
# 5: Find the paths for each component
340+
components = []
341+
components_closed = []
342+
while len(E.nonzero()[0]) > 0:
343+
# This works because eulerPath mutates the graph as it goes
344+
path = eulerPath(E)
345+
if path is None:
346+
raise ValueError("mesh slice has too many odd degree edges; can't find a path along the edge")
347+
component_verts = verts[path]
348+
349+
if np.all(component_verts[0] == component_verts[-1]):
350+
# Because the closed polyline will make that last link:
351+
component_verts = np.delete(component_verts, 0, axis=0)
352+
components_closed.append(True)
353+
else:
354+
components_closed.append(False)
355+
components.append(component_verts)
356+
357+
if neighborhood is None or len(components) == 1:
358+
return [Polyline(v, closed=closed) for v, closed in zip(components, components_closed)]
359+
360+
# 6 (optional - only if 'neighborhood' is provided): Use a KDTree to select the component with minimal distance to 'neighborhood'
304361
from scipy.spatial import cKDTree # First thought this warning was caused by a pythonpath problem, but it seems more likely that the warning is caused by scipy import hackery. pylint: disable=no-name-in-module
305-
from scipy.sparse import csc_matrix
306-
from scipy.sparse.csgraph import connected_components
307-
308-
from bodylabs.mesh.topology.connectivity import remove_redundant_verts
309-
310-
# get rid of redundancies, or we
311-
# overcount connected components
312-
v, e = remove_redundant_verts(v, e)
313-
314-
# connxns:
315-
# sparse matrix of connected components.
316-
# ij:
317-
# edges transposed
318-
# (connected_components needs these.)
319-
ij = np.vstack((
320-
e[:, 0].reshape(1, e.shape[0]),
321-
e[:, 1].reshape(1, e.shape[0]),
322-
))
323-
connxns = csc_matrix((np.ones(len(e)), ij), shape=(len(v), len(v)))
324-
325-
cmp_N, cmp_labels = connected_components(connxns)
326-
327-
if cmp_N == 1:
328-
# no work to do, bail
329-
polyline = Polyline(v, closed=True)
330-
# This function used to return (v, e), so we include this
331-
# sanity check to make sure the edges match what Polyline uses.
332-
# np.testing.assert_all_equal(polyline.e, e)
333-
# Hmm, this fails.
334-
return polyline
335-
336-
cmps = np.array([
337-
v[np.where(cmp_labels == cmp_i)]
338-
for cmp_i in range(cmp_N)
339-
])
340362

341363
kdtree = cKDTree(neighborhood)
342364

343-
# cmp_N will not be large in
344-
# practice, so this loop won't hurt
345-
means = np.array([
346-
np.mean(kdtree.query(cmps[cmp_i])[0])
347-
for cmp_i in range(cmp_N)
348-
])
349-
350-
which_cmp = np.where(means == np.min(means))[0][0]
351-
352-
# re-index edge mapping based on which_cmp. necessary
353-
# particularly when which_cmp is not contiguous in cmp_labels.
354-
which_vs = np.where(cmp_labels == which_cmp)[0]
355-
# which_es = np.logical_or(
356-
# np.in1d(e[:, 0], which_vs),
357-
# np.in1d(e[:, 1], which_vs),
358-
# )
359-
360-
vmap = cmp_labels.astype(float)
361-
vmap[cmp_labels != which_cmp] = np.nan
362-
vmap[cmp_labels == which_cmp] = np.arange(which_vs.size)
363-
364-
cmp_v = v[which_vs] # equivalently, cmp_v = cmp[which_cmp]
365-
# cmp_e = vmap[e[which_es]].astype(int)
366-
367-
polyline = Polyline(cmp_v, closed=True)
368-
# This function used to return (cmp_v, cmp_e), so we include this
369-
# sanity check to make sure the edges match what Polyline uses.
370-
# Remove # this, and probably the code which creates 'vmap', when
371-
# we're more confident.
372-
# Hmm, this fails.
373-
# np.testing.assert_all_equal(polyline.e, cmp_e)
374-
return polyline
365+
# number of components will not be large in practice, so this loop won't hurt
366+
means = [np.mean(kdtree.query(component)[0]) for component in components]
367+
return [Polyline(components[np.argmin(means)], closed=True)]
375368

376369

377370
def main():

blmath/geometry/primitives/polyline.py

+14-2
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,18 @@ def __init__(self, v, closed=False):
3030
self.__dict__['closed'] = closed
3131
self.v = v
3232

33+
def __repr__(self):
34+
if self.v is not None and len(self.v) != 0:
35+
if self.closed:
36+
return "<closed Polyline with {} verts>".format(len(self))
37+
else:
38+
return "<open Polyline with {} verts>".format(len(self))
39+
else:
40+
return "<Polyline with no verts>"
41+
42+
def __len__(self):
43+
return len(self.v)
44+
3345
def copy(self):
3446
'''
3547
Return a copy of this polyline.
@@ -38,13 +50,13 @@ def copy(self):
3850
v = None if self.v is None else np.copy(self.v)
3951
return self.__class__(v, closed=self.closed)
4052

41-
def as_lines(self):
53+
def as_lines(self, vc=None):
4254
'''
4355
Return a Lines instance with our vertices and edges.
4456
4557
'''
4658
from lace.lines import Lines
47-
return Lines(v=self.v, e=self.e)
59+
return Lines(v=self.v, e=self.e, vc=vc)
4860

4961
def to_dict(self, decimals=3):
5062
return {

0 commit comments

Comments
 (0)