437 lines
13 KiB
Python
437 lines
13 KiB
Python
import numpy as np
|
|
|
|
import copy
|
|
import collections
|
|
|
|
from . import arc
|
|
from . import entities
|
|
|
|
from .. import util
|
|
|
|
from ..nsphere import fit_nsphere
|
|
|
|
from ..constants import log
|
|
from ..constants import tol_path as tol
|
|
|
|
|
|
def fit_circle_check(points,
|
|
scale,
|
|
prior=None,
|
|
final=False,
|
|
verbose=False):
|
|
"""
|
|
Fit a circle, and reject the fit if:
|
|
* the radius is larger than tol.radius_min*scale or tol.radius_max*scale
|
|
* any segment spans more than tol.seg_angle
|
|
* any segment is longer than tol.seg_frac*scale
|
|
* the fit deviates by more than tol.radius_frac*radius
|
|
* the segments on the ends deviate from tangent by more than tol.tangent
|
|
|
|
Parameters
|
|
---------
|
|
points : (n, d)
|
|
List of points which represent a path
|
|
prior : (center, radius) tuple
|
|
Best guess or None if unknown
|
|
scale : float
|
|
What is the overall scale of the set of points
|
|
verbose : bool
|
|
Output log.debug messages for the reasons
|
|
for fit rejection only suggested for manual debugging
|
|
|
|
Returns
|
|
-----------
|
|
if fit is acceptable:
|
|
(center, radius) tuple
|
|
else:
|
|
None
|
|
"""
|
|
# an arc needs at least three points
|
|
if len(points) < 3:
|
|
return None
|
|
# make sure our points are a numpy array
|
|
points = np.asanyarray(points, dtype=np.float64)
|
|
|
|
# do a least squares fit on the points
|
|
C, R, r_deviation = fit_nsphere(points, prior=prior)
|
|
|
|
# check to make sure radius is between min and max allowed
|
|
if not tol.radius_min < (R / scale) < tol.radius_max:
|
|
if verbose:
|
|
log.debug('circle fit error: R %f', R / scale)
|
|
return None
|
|
|
|
# check point radius error
|
|
r_error = r_deviation / R
|
|
if r_error > tol.radius_frac:
|
|
if verbose:
|
|
log.debug('circle fit error: fit %s', str(r_error))
|
|
return None
|
|
|
|
vectors = np.diff(points, axis=0)
|
|
segment = util.row_norm(vectors)
|
|
|
|
# approximate angle in radians, segments are linear length
|
|
# not arc length but this is close and avoids a cosine
|
|
angle = segment / R
|
|
if (angle > tol.seg_angle).any():
|
|
if verbose:
|
|
log.debug('circle fit error: angle %s', str(angle))
|
|
return None
|
|
|
|
if final and (angle > tol.seg_angle_min).sum() < 3:
|
|
log.debug('final: angle %s', str(angle))
|
|
return None
|
|
|
|
# check segment length as a fraction of drawing scale
|
|
scaled = segment / scale
|
|
|
|
if (scaled > tol.seg_frac).any():
|
|
if verbose:
|
|
log.debug('circle fit error: segment %s', str(scaled))
|
|
return None
|
|
|
|
# check to make sure the line segments on the ends are actually
|
|
# tangent with the candidate circle fit
|
|
mid_pt = points[[0, -2]] + (vectors[[0, -1]] * .5)
|
|
radial = util.unitize(mid_pt - C)
|
|
ends = util.unitize(vectors[[0, -1]])
|
|
tangent = np.abs(np.arccos(util.diagonal_dot(radial, ends)))
|
|
tangent = np.abs(tangent - np.pi / 2).max()
|
|
|
|
if tangent > tol.tangent:
|
|
if verbose:
|
|
log.debug('circle fit error: tangent %f',
|
|
np.degrees(tangent))
|
|
return None
|
|
|
|
result = {'center': C,
|
|
'radius': R}
|
|
|
|
return result
|
|
|
|
|
|
def is_circle(points, scale, verbose=False):
|
|
"""
|
|
Given a set of points, quickly determine if they represent
|
|
a circle or not.
|
|
|
|
Parameters
|
|
-------------
|
|
points : (n,2 ) float
|
|
Points in space
|
|
scale : float
|
|
Scale of overall drawing
|
|
verbose : bool
|
|
Print all fit messages or not
|
|
|
|
Returns
|
|
-------------
|
|
control: (3,2) float, points in space, OR
|
|
None, if not a circle
|
|
"""
|
|
|
|
# make sure input is a numpy array
|
|
points = np.asanyarray(points)
|
|
scale = float(scale)
|
|
|
|
# can only be a circle if the first and last point are the
|
|
# same (AKA is a closed path)
|
|
if np.linalg.norm(points[0] - points[-1]) > tol.merge:
|
|
return None
|
|
|
|
box = points.ptp(axis=0)
|
|
# the bounding box size of the points
|
|
# check aspect ratio as an early exit if the path is not a circle
|
|
aspect = np.divide(*box)
|
|
if np.abs(aspect - 1.0) > tol.aspect_frac:
|
|
return None
|
|
|
|
# fit a circle with tolerance checks
|
|
CR = fit_circle_check(points, scale=scale)
|
|
if CR is None:
|
|
return None
|
|
|
|
# return the circle as three control points
|
|
control = arc.to_threepoint(**CR)
|
|
return control
|
|
|
|
|
|
def merge_colinear(points, scale):
|
|
"""
|
|
Given a set of points representing a path in space,
|
|
merge points which are colinear.
|
|
|
|
Parameters
|
|
----------
|
|
points : (n, dimension) float
|
|
Points in space
|
|
scale : float
|
|
Scale of drawing for precision
|
|
|
|
Returns
|
|
----------
|
|
merged : (j, d) float
|
|
Points with colinear and duplicate
|
|
points merged, where (j < n)
|
|
"""
|
|
points = np.asanyarray(points, dtype=np.float64)
|
|
scale = float(scale)
|
|
|
|
if len(points.shape) != 2 or points.shape[1] != 2:
|
|
raise ValueError('only for 2D points!')
|
|
|
|
# if there's less than 3 points nothing to merge
|
|
if len(points) < 3:
|
|
return points.copy()
|
|
|
|
# the vector from one point to the next
|
|
direction = points[1:] - points[:-1]
|
|
# the length of the direction vector
|
|
direction_norm = util.row_norm(direction)
|
|
# make sure points don't have zero length
|
|
direction_ok = direction_norm > tol.merge
|
|
|
|
# remove duplicate points
|
|
points = np.vstack((points[0], points[1:][direction_ok]))
|
|
direction = direction[direction_ok]
|
|
direction_norm = direction_norm[direction_ok]
|
|
|
|
# create a vector between every other point, then turn it perpendicular
|
|
# if we have points A B C D
|
|
# and direction vectors A-B, B-C, etc
|
|
# these will be perpendicular to the vectors A-C, B-D, etc
|
|
perp = (points[2:] - points[:-2]).T[::-1].T
|
|
perp_norm = util.row_norm(perp)
|
|
perp_nonzero = perp_norm > tol.merge
|
|
perp[perp_nonzero] /= perp_norm[perp_nonzero].reshape((-1, 1))
|
|
|
|
# find the projection of each direction vector
|
|
# onto the perpendicular vector
|
|
projection = np.abs(util.diagonal_dot(perp,
|
|
direction[:-1]))
|
|
|
|
projection_ratio = np.max((projection / direction_norm[1:],
|
|
projection / direction_norm[:-1]), axis=0)
|
|
|
|
mask = np.ones(len(points), dtype=np.bool)
|
|
# since we took diff, we need to offset by one
|
|
mask[1:-1][projection_ratio < 1e-4 * scale] = False
|
|
|
|
merged = points[mask]
|
|
return merged
|
|
|
|
|
|
def resample_spline(points, smooth=.001, count=None, degree=3):
|
|
"""
|
|
Resample a path in space, smoothing along a b-spline.
|
|
|
|
Parameters
|
|
-----------
|
|
points : (n, dimension) float
|
|
Points in space
|
|
smooth : float
|
|
Smoothing distance
|
|
count : int or None
|
|
Number of samples desired in output
|
|
degree : int
|
|
Degree of spline polynomial
|
|
|
|
Returns
|
|
---------
|
|
resampled : (count, dimension) float
|
|
Points in space
|
|
"""
|
|
from scipy.interpolate import splprep, splev
|
|
if count is None:
|
|
count = len(points)
|
|
points = np.asanyarray(points)
|
|
closed = np.linalg.norm(points[0] - points[-1]) < tol.merge
|
|
|
|
tpl = splprep(points.T, s=smooth, k=degree)[0]
|
|
i = np.linspace(0.0, 1.0, count)
|
|
resampled = np.column_stack(splev(i, tpl))
|
|
|
|
if closed:
|
|
shared = resampled[[0, -1]].mean(axis=0)
|
|
resampled[0] = shared
|
|
resampled[-1] = shared
|
|
|
|
return resampled
|
|
|
|
|
|
def points_to_spline_entity(points, smooth=None, count=None):
|
|
"""
|
|
Create a spline entity from a curve in space
|
|
|
|
Parameters
|
|
-----------
|
|
points : (n, dimension) float
|
|
Points in space
|
|
smooth : float
|
|
Smoothing distance
|
|
count : int or None
|
|
Number of samples desired in result
|
|
|
|
Returns
|
|
---------
|
|
entity : entities.BSpline
|
|
Entity object with points indexed at zero
|
|
control : (m, dimension) float
|
|
New vertices for entity
|
|
"""
|
|
|
|
from scipy.interpolate import splprep
|
|
if count is None:
|
|
count = len(points)
|
|
if smooth is None:
|
|
smooth = 0.002
|
|
|
|
points = np.asanyarray(points, dtype=np.float64)
|
|
closed = np.linalg.norm(points[0] - points[-1]) < tol.merge
|
|
|
|
knots, control, degree = splprep(points.T, s=smooth)[0]
|
|
control = np.transpose(control)
|
|
index = np.arange(len(control))
|
|
|
|
if closed:
|
|
control[0] = control[[0, -1]].mean(axis=0)
|
|
control = control[:-1]
|
|
index[-1] = index[0]
|
|
|
|
entity = entities.BSpline(points=index,
|
|
knots=knots,
|
|
closed=closed)
|
|
|
|
return entity, control
|
|
|
|
|
|
def simplify_basic(drawing, process=False, **kwargs):
|
|
"""
|
|
Merge colinear segments and fit circles.
|
|
|
|
Parameters
|
|
-----------
|
|
drawing : Path2D
|
|
Source geometry, will not be modified
|
|
|
|
Returns
|
|
-----------
|
|
simplified : Path2D
|
|
Original path but with some closed line-loops converted to circles
|
|
"""
|
|
|
|
if any(i.__class__.__name__ != 'Line'
|
|
for i in drawing.entities):
|
|
log.debug('Path contains non- linear entities, skipping')
|
|
return drawing
|
|
|
|
# we are going to do a bookkeeping to avoid having
|
|
# to recompute literally everything when simplification is ran
|
|
cache = copy.deepcopy(drawing._cache)
|
|
|
|
# store new values
|
|
vertices_new = collections.deque()
|
|
entities_new = collections.deque()
|
|
|
|
# avoid thrashing cache in loop
|
|
scale = drawing.scale
|
|
|
|
# loop through (n, 2) closed paths
|
|
for discrete in drawing.discrete:
|
|
# check to see if the closed entity is a circle
|
|
circle = is_circle(discrete,
|
|
scale=scale)
|
|
if circle is not None:
|
|
# the points are circular enough for our high standards
|
|
# so replace them with a closed Arc entity
|
|
entities_new.append(entities.Arc(points=np.arange(3) +
|
|
len(vertices_new),
|
|
closed=True))
|
|
vertices_new.extend(circle)
|
|
else:
|
|
# not a circle, so clean up colinear segments
|
|
# then save it as a single line entity
|
|
points = merge_colinear(discrete, scale=scale)
|
|
# references for new vertices
|
|
indexes = np.arange(len(points)) + len(vertices_new)
|
|
# discrete curves are always closed
|
|
indexes[-1] = indexes[0]
|
|
# append new vertices and entity
|
|
entities_new.append(entities.Line(points=indexes))
|
|
vertices_new.extend(points)
|
|
|
|
# create the new drawing object
|
|
simplified = type(drawing)(
|
|
entities=entities_new,
|
|
vertices=vertices_new,
|
|
metadata=copy.deepcopy(drawing.metadata),
|
|
process=process)
|
|
# we have changed every path to a single closed entity
|
|
# either a closed arc, or a closed line
|
|
# so all closed paths are now represented by a single entity
|
|
cache.cache.update({
|
|
'paths': np.arange(len(entities_new)).reshape((-1, 1)),
|
|
'path_valid': np.ones(len(entities_new), dtype=np.bool),
|
|
'dangling': np.array([])})
|
|
|
|
# force recompute of exact bounds
|
|
if 'bounds' in cache.cache:
|
|
cache.cache.pop('bounds')
|
|
|
|
simplified._cache = cache
|
|
# set the cache ID so it won't dump when a value is requested
|
|
simplified._cache.id_set()
|
|
|
|
return simplified
|
|
|
|
|
|
def simplify_spline(path, smooth=None, verbose=False):
|
|
"""
|
|
Replace discrete curves with b-spline or Arc and
|
|
return the result as a new Path2D object.
|
|
|
|
Parameters
|
|
------------
|
|
path : trimesh.path.Path2D
|
|
Input geometry
|
|
smooth : float
|
|
Distance to smooth
|
|
|
|
Returns
|
|
------------
|
|
simplified : Path2D
|
|
Consists of Arc and BSpline entities
|
|
"""
|
|
|
|
new_vertices = []
|
|
new_entities = []
|
|
scale = path.scale
|
|
|
|
for discrete in path.discrete:
|
|
circle = is_circle(discrete,
|
|
scale=scale,
|
|
verbose=verbose)
|
|
if circle is not None:
|
|
# the points are circular enough for our high standards
|
|
# so replace them with a closed Arc entity
|
|
new_entities.append(entities.Arc(points=np.arange(3) +
|
|
len(new_vertices),
|
|
closed=True))
|
|
new_vertices.extend(circle)
|
|
continue
|
|
|
|
# entities for this path
|
|
entity, vertices = points_to_spline_entity(discrete, smooth=smooth)
|
|
# reindex returned control points
|
|
entity.points += len(new_vertices)
|
|
# save entity and vertices
|
|
new_vertices.extend(vertices)
|
|
new_entities.append(entity)
|
|
|
|
# create the Path2D object for the result
|
|
simplified = type(path)(entities=new_entities,
|
|
vertices=new_vertices)
|
|
|
|
return simplified
|