554 lines
17 KiB
Python
554 lines
17 KiB
Python
"""
|
|
segments.py
|
|
--------------
|
|
|
|
Deal with (n, 2, 3) line segments.
|
|
"""
|
|
|
|
import numpy as np
|
|
|
|
from .. import util
|
|
from .. import grouping
|
|
from .. import geometry
|
|
from .. import interval
|
|
from .. import transformations
|
|
|
|
from ..constants import tol
|
|
|
|
|
|
def segments_to_parameters(segments):
|
|
"""
|
|
For 3D line segments defined by two points, turn
|
|
them in to an origin defined as the closest point along
|
|
the line to the zero origin as well as a direction vector
|
|
and start and end parameter.
|
|
|
|
Parameters
|
|
------------
|
|
segments : (n, 2, 3) float
|
|
Line segments defined by start and end points
|
|
|
|
Returns
|
|
--------------
|
|
origins : (n, 3) float
|
|
Point on line closest to [0, 0, 0]
|
|
vectors : (n, 3) float
|
|
Unit line directions
|
|
parameters : (n, 2) float
|
|
Start and end distance pairs for each line
|
|
"""
|
|
segments = np.asanyarray(segments, dtype=np.float64)
|
|
if not util.is_shape(segments, (-1, 2, (2, 3))):
|
|
raise ValueError('incorrect segment shape!',
|
|
segments.shape)
|
|
|
|
# make the initial origin one of the end points
|
|
endpoint = segments[:, 0]
|
|
vectors = segments[:, 1] - endpoint
|
|
vectors_norm = util.row_norm(vectors)
|
|
vectors /= vectors_norm.reshape((-1, 1))
|
|
|
|
# find the point along the line nearest the origin
|
|
offset = util.diagonal_dot(endpoint, vectors)
|
|
# points nearest [0, 0, 0] will be our new origin
|
|
origins = endpoint + (offset.reshape((-1, 1)) * -vectors)
|
|
|
|
# parametric start and end of line segment
|
|
parameters = np.column_stack((offset, offset + vectors_norm))
|
|
|
|
return origins, vectors, parameters
|
|
|
|
|
|
def parameters_to_segments(origins, vectors, parameters):
|
|
"""
|
|
Convert a parametric line segment representation to
|
|
a two point line segment representation
|
|
|
|
Parameters
|
|
------------
|
|
origins : (n, 3) float
|
|
Line origin point
|
|
vectors : (n, 3) float
|
|
Unit line directions
|
|
parameters : (n, 2) float
|
|
Start and end distance pairs for each line
|
|
|
|
Returns
|
|
--------------
|
|
segments : (n, 2, 3) float
|
|
Line segments defined by start and end points
|
|
"""
|
|
# don't copy input
|
|
origins = np.asanyarray(origins, dtype=np.float64)
|
|
vectors = np.asanyarray(vectors, dtype=np.float64)
|
|
parameters = np.asanyarray(parameters, dtype=np.float64)
|
|
|
|
# turn the segments into a reshapable 2D array
|
|
segments = np.hstack((origins + vectors * parameters[:, :1],
|
|
origins + vectors * parameters[:, 1:]))
|
|
|
|
return segments.reshape((-1, 2, origins.shape[1]))
|
|
|
|
|
|
def colinear_pairs(segments,
|
|
radius=.01,
|
|
angle=.01,
|
|
length=None):
|
|
"""
|
|
Find pairs of segments which are colinear.
|
|
|
|
Parameters
|
|
-------------
|
|
segments : (n, 2, (2, 3)) float
|
|
Two or three dimensional line segments
|
|
radius : float
|
|
Maximum radius line origins can differ
|
|
and be considered colinear
|
|
angle : float
|
|
Maximum angle in radians segments can
|
|
differ and still be considered colinear
|
|
length : None or float
|
|
If specified, will additionally require
|
|
that pairs have a mean vertex distance less
|
|
than this value from each other to qualify.
|
|
|
|
Returns
|
|
------------
|
|
pairs : (m, 2) int
|
|
Indexes of segments which are colinear
|
|
"""
|
|
from scipy import spatial
|
|
|
|
# convert segments to parameterized origins
|
|
# which are the closest point on the line to
|
|
# the actual zero- origin
|
|
origins, vectors, param = segments_to_parameters(segments)
|
|
|
|
# create a kdtree for origins
|
|
tree = spatial.cKDTree(origins)
|
|
|
|
# find origins closer than specified radius
|
|
pairs = tree.query_pairs(r=radius, output_type='ndarray')
|
|
|
|
# calculate angles between pairs
|
|
angles = geometry.vector_angle(vectors[pairs])
|
|
|
|
# angles can be within tolerance of 180 degrees or 0.0 degrees
|
|
angle_ok = np.logical_or(
|
|
util.isclose(angles, np.pi, atol=angle),
|
|
util.isclose(angles, 0.0, atol=angle))
|
|
|
|
# apply angle threshold
|
|
colinear = pairs[angle_ok]
|
|
|
|
# if length is specified check endpoint proximity
|
|
if length is not None:
|
|
# make sure parameter pairs are ordered
|
|
param.sort(axis=1)
|
|
# calculate the mean parameter distance for each colinear pair
|
|
distance = param[colinear].ptp(axis=1).mean(axis=1)
|
|
# if the MEAN distance is less than specified length consider
|
|
# the segment to be identical: worst case single- vertex
|
|
# distance is 2*length
|
|
identical = distance < length
|
|
# remove non- identical pairs
|
|
colinear = colinear[identical]
|
|
|
|
return colinear
|
|
|
|
|
|
def split(segments, points, atol=1e-5):
|
|
"""
|
|
Find any points that lie on a segment (not an endpoint)
|
|
and then split that segment into two segments.
|
|
|
|
We are basically going to find the distance between
|
|
point and both segment vertex, and see if it is with
|
|
tolerance of the segment length.
|
|
|
|
Parameters
|
|
--------------
|
|
segments : (n, 2, (2, 3) float
|
|
Line segments in space
|
|
points : (n, (2, 3)) float
|
|
Points in space
|
|
atol : float
|
|
Absolute tolerance for distances
|
|
|
|
Returns
|
|
-------------
|
|
split : (n, 2, (3 | 3) float
|
|
Line segments in space, split at vertices
|
|
"""
|
|
|
|
points = np.asanyarray(points, dtype=np.float64)
|
|
segments = np.asanyarray(segments, dtype=np.float64)
|
|
# reshape to a flat 2D (n, dimension) array
|
|
seg_flat = segments.reshape((-1, segments.shape[2]))
|
|
|
|
# find the length of every segment
|
|
length = ((segments[:, 0, :] -
|
|
segments[:, 1, :]) ** 2).sum(axis=1) ** 0.5
|
|
|
|
# a mask to remove segments we split at the end
|
|
keep = np.ones(len(segments), dtype=np.bool)
|
|
# append new segments to a list
|
|
new_seg = []
|
|
|
|
# loop through every point
|
|
for p in points:
|
|
# note that you could probably get a speedup
|
|
# by using scipy.spatial.distance.cdist here
|
|
|
|
# find the distance from point to every segment endpoint
|
|
pair = ((seg_flat - p) ** 2).sum(
|
|
axis=1).reshape((-1, 2)) ** 0.5
|
|
# point is on a segment if it is not on a vertex
|
|
# and the sum length is equal to the actual segment length
|
|
on_seg = np.logical_and(
|
|
util.isclose(length, pair.sum(axis=1), atol=atol),
|
|
~util.isclose(pair, 0.0, atol=atol).any(axis=1))
|
|
|
|
# if we have any points on the segment split it in twain
|
|
if on_seg.any():
|
|
# remove the original segment
|
|
keep = np.logical_and(keep, ~on_seg)
|
|
# split every segment that this point lies on
|
|
for seg in segments[on_seg]:
|
|
new_seg.append([p, seg[0]])
|
|
new_seg.append([p, seg[1]])
|
|
|
|
if len(new_seg) > 0:
|
|
return np.vstack((segments[keep], new_seg))
|
|
else:
|
|
return segments
|
|
|
|
|
|
def unique(segments, digits=5):
|
|
"""
|
|
Find unique non-zero line segments.
|
|
|
|
Parameters
|
|
------------
|
|
segments : (n, 2, (2|3)) float
|
|
Line segments in space
|
|
digits : int
|
|
How many digits to consider when merging vertices
|
|
|
|
Returns
|
|
-----------
|
|
unique : (m, 2, (2|3)) float
|
|
Segments with duplicates merged
|
|
"""
|
|
segments = np.asanyarray(segments, dtype=np.float64)
|
|
|
|
# find segments as unique indexes so we can find duplicates
|
|
inverse = grouping.unique_rows(
|
|
segments.reshape((-1, segments.shape[2])),
|
|
digits=digits)[1].reshape((-1, 2))
|
|
# make sure rows are sorted
|
|
inverse.sort(axis=1)
|
|
# remove segments where both indexes are the same
|
|
mask = np.zeros(len(segments), dtype=np.bool)
|
|
# only include the first occurrence of a segment
|
|
mask[grouping.unique_rows(inverse)[0]] = True
|
|
# remove segments that are zero-length
|
|
mask[inverse[:, 0] == inverse[:, 1]] = False
|
|
# apply the unique mask
|
|
unique = segments[mask]
|
|
|
|
return unique
|
|
|
|
|
|
def overlap(origins, vectors, params):
|
|
"""
|
|
Find the overlap of two parallel line segments.
|
|
|
|
Parameters
|
|
------------
|
|
origins : (2, 3) float
|
|
Origin points of lines in space
|
|
vectors : (2, 3) float
|
|
Unit direction vectors of lines
|
|
params : (2, 2) float
|
|
Two (start, end) distance pairs
|
|
|
|
Returns
|
|
------------
|
|
length : float
|
|
Overlapping length
|
|
overlap : (n, 2, 3) float
|
|
Line segments for overlapping distance
|
|
"""
|
|
# copy inputs and make sure shape is correct
|
|
origins = np.array(origins).reshape((2, 3))
|
|
vectors = np.array(vectors).reshape((2, 3))
|
|
params = np.array(params).reshape((2, 2))
|
|
|
|
if tol.strict:
|
|
# convert input to parameters before flipping
|
|
# to make sure we didn't screw it up
|
|
truth = parameters_to_segments(origins,
|
|
vectors,
|
|
params)
|
|
|
|
# this function only works on parallel lines
|
|
dot = np.dot(*vectors)
|
|
assert np.isclose(np.abs(dot), 1.0, atol=.01)
|
|
|
|
# if two vectors are reversed
|
|
if dot < 0.0:
|
|
# reverse direction vector
|
|
vectors[1] *= -1.0
|
|
# negate parameters
|
|
params[1] *= -1.0
|
|
|
|
if tol.strict:
|
|
# do a check to make sure our reversal didn't
|
|
# inadvertently give us incorrect segments
|
|
assert np.allclose(truth,
|
|
parameters_to_segments(origins,
|
|
vectors,
|
|
params))
|
|
|
|
# merge the parameter ranges
|
|
ok, new_range = interval.intersection(*params)
|
|
|
|
if not ok:
|
|
return 0.0, np.array([])
|
|
|
|
# create the overlapping segment pairs (2, 2, 3)
|
|
segments = np.array([o + v * new_range.reshape((-1, 1))
|
|
for o, v in zip(origins, vectors)])
|
|
# get the length of the new range
|
|
length = new_range.ptp()
|
|
|
|
return length, segments
|
|
|
|
|
|
def extrude(segments, height, double_sided=False):
|
|
"""
|
|
Extrude 2D line segments into 3D triangles.
|
|
|
|
Parameters
|
|
-------------
|
|
segments : (n, 2, 2) float
|
|
2D line segments
|
|
height : float
|
|
Distance to extrude along Z
|
|
double_sided : bool
|
|
If true, return 4 triangles per segment
|
|
|
|
Returns
|
|
-------------
|
|
vertices : (n, 3) float
|
|
Vertices in space
|
|
faces : (n, 3) int
|
|
Indices of vertices forming triangles
|
|
"""
|
|
segments = np.asanyarray(segments, dtype=np.float64)
|
|
if not util.is_shape(segments, (-1, 2, 2)):
|
|
raise ValueError('segments shape incorrect')
|
|
|
|
# we are creating two vertices triangles for every 2D line segment
|
|
# on the segments of the 2D triangulation
|
|
vertices = np.tile(segments.reshape((-1, 2)), 2).reshape((-1, 2))
|
|
vertices = np.column_stack((vertices,
|
|
np.tile([0, height, 0, height],
|
|
len(segments))))
|
|
faces = np.tile([3, 1, 2, 2, 1, 0],
|
|
(len(segments), 1))
|
|
faces += np.arange(len(segments)).reshape((-1, 1)) * 4
|
|
faces = faces.reshape((-1, 3))
|
|
|
|
if double_sided:
|
|
# stack so they will render from the back
|
|
faces = np.vstack((
|
|
faces, np.fliplr(faces)))
|
|
|
|
return vertices, faces
|
|
|
|
|
|
def length(segments, summed=True):
|
|
"""
|
|
Extrude 2D line segments into 3D triangles.
|
|
|
|
Parameters
|
|
-------------
|
|
segments : (n, 2, 2) float
|
|
2D line segments
|
|
height : float
|
|
Distance to extrude along Z
|
|
double_sided : bool
|
|
If true, return 4 triangles per segment
|
|
|
|
Returns
|
|
-------------
|
|
vertices : (n, 3) float
|
|
Vertices in space
|
|
faces : (n, 3) int
|
|
Indices of vertices forming triangles
|
|
"""
|
|
segments = np.asanyarray(segments)
|
|
norms = util.row_norm(segments[:, 0, :] - segments[:, 1, :])
|
|
if summed:
|
|
return norms.sum()
|
|
return norms
|
|
|
|
|
|
def resample(segments,
|
|
maxlen,
|
|
return_index=False,
|
|
return_count=False):
|
|
"""
|
|
Resample line segments until no segment
|
|
is longer than maxlen.
|
|
|
|
Parameters
|
|
-------------
|
|
segments : (n, 2, 2) float
|
|
2D line segments
|
|
maxlen : float
|
|
The maximum length of a line segment
|
|
return_index : bool
|
|
Return the index of the source segment
|
|
return_count : bool
|
|
Return how many segments each original was split into
|
|
|
|
Returns
|
|
-------------
|
|
resampled : (m, 2, 3) float
|
|
Line segments where no segment is longer than maxlen
|
|
index : (m,) int
|
|
[OPTIONAL] The index of segments resampled came from
|
|
count : (n,) int
|
|
[OPTIONAL] The count of the original segments
|
|
"""
|
|
# check arguments
|
|
maxlen = float(maxlen)
|
|
segments = np.array(segments, dtype=np.float64)
|
|
|
|
# shortcut for endpoints
|
|
pt1 = segments[:, 0]
|
|
pt2 = segments[:, 1]
|
|
# vector between endpoints
|
|
vec = pt2 - pt1
|
|
# the integer number of times a segment needs to be split
|
|
splits = np.ceil(util.row_norm(vec) / maxlen).astype(np.int64)
|
|
|
|
# save resulting segments
|
|
result = []
|
|
# save index of original segment
|
|
index = []
|
|
|
|
tile = np.tile
|
|
# generate the line indexes ahead of time
|
|
stacks = util.stack_lines(np.arange(splits.max() + 1))
|
|
|
|
# loop through each count of unique splits needed
|
|
for split in np.unique(splits):
|
|
# get a mask of which segments need to be split
|
|
mask = splits == split
|
|
# the vector for each incremental length
|
|
increment = vec[mask] / split
|
|
# stack the increment vector into the shape needed
|
|
v = (tile(increment, split + 1).reshape((-1, 3)) *
|
|
tile(np.arange(split + 1),
|
|
len(increment)).reshape((-1, 1)))
|
|
# stack the origin points correctly
|
|
o = tile(pt1[mask], split + 1).reshape((-1, 3))
|
|
# now get each segment as an (split, 3) polyline
|
|
poly = (o + v).reshape((-1, split + 1, 3))
|
|
# save the resulting segments
|
|
# magical slicing is equivalent to:
|
|
# > [p[stack] for p in poly]
|
|
result.extend(poly[:, stacks[:split]])
|
|
|
|
if return_index:
|
|
# get the original index from the mask
|
|
index_original = np.nonzero(mask)[0].reshape((-1, 1))
|
|
# save one entry per split segment
|
|
index.append((np.ones((len(poly), split),
|
|
dtype=np.int64) *
|
|
index_original).ravel())
|
|
if tol.strict:
|
|
# check to make sure every start and end point
|
|
# from the reconstructed result corresponds
|
|
for original, recon in zip(segments[mask], poly):
|
|
assert np.allclose(original[0], recon[0])
|
|
assert np.allclose(original[-1], recon[-1])
|
|
# make sure stack slicing was OK
|
|
assert np.allclose(
|
|
util.stack_lines(np.arange(split + 1)),
|
|
stacks[:split])
|
|
|
|
# stack into (n, 2, 3) segments
|
|
result = [np.concatenate(result)]
|
|
|
|
if tol.strict:
|
|
# make sure resampled segments have the same length as input
|
|
assert np.isclose(length(segments),
|
|
length(result[0]),
|
|
atol=1e-3)
|
|
|
|
# stack additional return options
|
|
if return_index:
|
|
# stack original indexes
|
|
index = np.concatenate(index)
|
|
if tol.strict:
|
|
# index should correspond to result
|
|
assert len(index) == len(result[0])
|
|
# every segment should be represented
|
|
assert set(index) == set(range(len(segments)))
|
|
result.append(index)
|
|
|
|
if return_count:
|
|
result.append(splits)
|
|
|
|
if len(result) == 1:
|
|
return result[0]
|
|
return result
|
|
|
|
|
|
def to_svg(segments, digits=4, matrix=None, merge=True):
|
|
"""
|
|
Convert (n, 2, 2) line segments to an SVG path string.
|
|
|
|
Parameters
|
|
------------
|
|
segments : (n, 2, 2) float
|
|
Line segments to convert
|
|
digits : int
|
|
Number of digits to include in SVG string
|
|
matrix : None or (3, 3) float
|
|
Homogeneous 2D transformation to apply before export
|
|
|
|
Returns
|
|
-----------
|
|
path : str
|
|
SVG path string with one line per segment
|
|
IE: 'M 0.1 0.2 L 10 12'
|
|
"""
|
|
segments = np.array(segments, copy=True)
|
|
if not util.is_shape(segments, (-1, 2, 2)):
|
|
raise ValueError('only for (n, 2, 2) segments!')
|
|
|
|
# create the array to export
|
|
|
|
# apply 2D transformation if passed
|
|
if matrix is not None:
|
|
segments = transformations.transform_points(
|
|
segments.reshape((-1, 2)),
|
|
matrix=matrix).reshape((-1, 2, 2))
|
|
|
|
if merge:
|
|
# remove duplicate and zero-length segments
|
|
segments = unique(segments, digits=digits)
|
|
|
|
# create the format string for a single line segment
|
|
base = ' M _ _ L _ _'.replace(
|
|
'_', '{:0.' + str(int(digits)) + 'f}')
|
|
# create one large format string then apply points
|
|
result = (base * len(segments))[1:].format(*segments.ravel())
|
|
return result
|