mightyscape-1.2/extensions/fablabchemnitz/cookie_cutter/svgpath/path.py

647 lines
23 KiB
Python

from __future__ import division
from math import sqrt, cos, sin, acos, degrees, radians, log
from collections.abc import MutableSequence
# This file contains classes for the different types of SVG path segments as
# well as a Path object that contains a sequence of path segments.
MIN_DEPTH = 5
ERROR = 1e-12
def segment_length(curve, start, end, start_point, end_point, error, min_depth, depth):
"""Recursively approximates the length by straight lines"""
mid = (start + end) / 2
mid_point = curve.point(mid)
length = abs(end_point - start_point)
first_half = abs(mid_point - start_point)
second_half = abs(end_point - mid_point)
length2 = first_half + second_half
if (length2 - length > error) or (depth < min_depth):
# Calculate the length of each segment:
depth += 1
return (segment_length(curve, start, mid, start_point, mid_point,
error, min_depth, depth) +
segment_length(curve, mid, end, mid_point, end_point,
error, min_depth, depth))
# This is accurate enough.
return length2
def approximate(path, start, end, start_point, end_point, max_error, depth, max_depth):
if depth >= max_depth:
return [start_point, end_point]
actual_length = path.measure(start, end, error=max_error/4)
linear_length = abs(end_point - start_point)
# Worst case deviation given a fixed linear_length and actual_length would probably be
# a symmetric tent shape (I haven't proved it -- TODO).
deviationSquared = (actual_length/2)**2 - (linear_length/2)**2
if deviationSquared <= max_error ** 2:
return [start_point, end_point]
else:
mid = (start+end)/2.
mid_point = path.point(mid)
return ( approximate(path, start, mid, start_point, mid_point, max_error, depth+1, max_depth)[:-1] +
approximate(path, mid, end, mid_point, end_point, max_error, depth+1, max_depth) )
def removeCollinear(points, error, pointsToKeep=set()):
out = []
lengths = [0]
for i in range(1,len(points)):
lengths.append(lengths[-1] + abs(points[i]-points[i-1]))
def length(a,b):
return lengths[b] - lengths[a]
i = 0
while i < len(points):
j = len(points) - 1
while i < j:
deviationSquared = (length(i, j)/2)**2 - (abs(points[j]-points[i])/2)**2
if deviationSquared <= error ** 2 and set(range(i+1,j)).isdisjoint(pointsToKeep):
out.append(points[i])
i = j
break
j -= 1
out.append(points[j])
i += 1
return out
class Segment(object):
def __init__(self, start, end):
self.start = start
self.end = end
def measure(self, start, end, error=ERROR, min_depth=MIN_DEPTH):
return Path(self).measure(start, end, error=error, min_depth=min_depth)
def getApproximatePoints(self, error=0.001, max_depth=32):
points = approximate(self, 0., 1., self.point(0.), self.point(1.), error, 0, max_depth)
return points
class Line(Segment):
def __init__(self, start, end):
super(Line, self).__init__(start,end)
def __repr__(self):
return 'Line(start=%s, end=%s)' % (self.start, self.end)
def __eq__(self, other):
if not isinstance(other, Line):
return NotImplemented
return self.start == other.start and self.end == other.end
def __ne__(self, other):
if not isinstance(other, Line):
return NotImplemented
return not self == other
def getApproximatePoints(self, error=0.001, max_depth=32):
return [self.start, self.end]
def point(self, pos):
if pos == 0.:
return self.start
elif pos == 1.:
return self.end
distance = self.end - self.start
return self.start + distance * pos
def length(self, error=None, min_depth=None):
distance = (self.end - self.start)
return sqrt(distance.real ** 2 + distance.imag ** 2)
class CubicBezier(Segment):
def __init__(self, start, control1, control2, end):
super(CubicBezier, self).__init__(start,end)
self.control1 = control1
self.control2 = control2
def __repr__(self):
return 'CubicBezier(start=%s, control1=%s, control2=%s, end=%s)' % (
self.start, self.control1, self.control2, self.end)
def __eq__(self, other):
if not isinstance(other, CubicBezier):
return NotImplemented
return self.start == other.start and self.end == other.end and \
self.control1 == other.control1 and self.control2 == other.control2
def __ne__(self, other):
if not isinstance(other, CubicBezier):
return NotImplemented
return not self == other
def is_smooth_from(self, previous):
"""Checks if this segment would be a smooth segment following the previous"""
if isinstance(previous, CubicBezier):
return (self.start == previous.end and
(self.control1 - self.start) == (previous.end - previous.control2))
else:
return self.control1 == self.start
def point(self, pos):
"""Calculate the x,y position at a certain position of the path"""
if pos == 0.:
return self.start
elif pos == 1.:
return self.end
return ((1 - pos) ** 3 * self.start) + \
(3 * (1 - pos) ** 2 * pos * self.control1) + \
(3 * (1 - pos) * pos ** 2 * self.control2) + \
(pos ** 3 * self.end)
def length(self, error=ERROR, min_depth=MIN_DEPTH):
"""Calculate the length of the path up to a certain position"""
start_point = self.point(0)
end_point = self.point(1)
return segment_length(self, 0, 1, start_point, end_point, error, min_depth, 0)
class QuadraticBezier(Segment):
def __init__(self, start, control, end):
super(QuadraticBezier, self).__init__(start,end)
self.control = control
def __repr__(self):
return 'QuadraticBezier(start=%s, control=%s, end=%s)' % (
self.start, self.control, self.end)
def __eq__(self, other):
if not isinstance(other, QuadraticBezier):
return NotImplemented
return self.start == other.start and self.end == other.end and \
self.control == other.control
def __ne__(self, other):
if not isinstance(other, QuadraticBezier):
return NotImplemented
return not self == other
def is_smooth_from(self, previous):
"""Checks if this segment would be a smooth segment following the previous"""
if isinstance(previous, QuadraticBezier):
return (self.start == previous.end and
(self.control - self.start) == (previous.end - previous.control))
else:
return self.control == self.start
def point(self, pos):
if pos == 0.:
return self.start
elif pos == 1.:
return self.end
return (1 - pos) ** 2 * self.start + 2 * (1 - pos) * pos * self.control + \
pos ** 2 * self.end
def length(self, error=None, min_depth=None):
a = self.start - 2*self.control + self.end
b = 2*(self.control - self.start)
a_dot_b = a.real*b.real + a.imag*b.imag
if abs(a) < 1e-12:
s = abs(b)
elif abs(a_dot_b + abs(a)*abs(b)) < 1e-12:
k = abs(b)/abs(a)
if k >= 2:
s = abs(b) - abs(a)
else:
s = abs(a)*(k**2/2 - k + 1)
else:
# For an explanation of this case, see
# http://www.malczak.info/blog/quadratic-bezier-curve-length/
A = 4 * (a.real ** 2 + a.imag ** 2)
B = 4 * (a.real * b.real + a.imag * b.imag)
C = b.real ** 2 + b.imag ** 2
Sabc = 2 * sqrt(A + B + C)
A2 = sqrt(A)
A32 = 2 * A * A2
C2 = 2 * sqrt(C)
BA = B / A2
s = (A32 * Sabc + A2 * B * (Sabc - C2) + (4 * C * A - B ** 2) *
log((2 * A2 + BA + Sabc) / (BA + C2))) / (4 * A32)
return s
class Arc(Segment):
def __init__(self, start, radius, rotation, arc, sweep, end, scaler=lambda z:z):
"""radius is complex, rotation is in degrees,
large and sweep are 1 or 0 (True/False also work)"""
super(Arc, self).__init__(scaler(start),scaler(end))
self.start0 = start
self.end0 = end
self.radius = radius
self.rotation = rotation
self.arc = bool(arc)
self.sweep = bool(sweep)
self.scaler = scaler
self._parameterize()
def __repr__(self):
return 'Arc(start0=%s, radius=%s, rotation=%s, arc=%s, sweep=%s, end0=%s, scaler=%s)' % (
self.start0, self.radius, self.rotation, self.arc, self.sweep, self.end0, self.scaler)
def __eq__(self, other):
if not isinstance(other, Arc):
return NotImplemented
return self.start == other.start and self.end == other.end and \
self.radius == other.radius and self.rotation == other.rotation and \
self.arc == other.arc and self.sweep == other.sweep
def __ne__(self, other):
if not isinstance(other, Arc):
return NotImplemented
return not self == other
def _parameterize(self):
# Conversion from endpoint to center parameterization
# http://www.w3.org/TR/SVG/implnote.html#ArcImplementationNotes
cosr = cos(radians(self.rotation))
sinr = sin(radians(self.rotation))
dx = (self.start0.real - self.end0.real) / 2
dy = (self.start0.imag - self.end0.imag) / 2
x1prim = cosr * dx + sinr * dy
x1prim_sq = x1prim * x1prim
y1prim = -sinr * dx + cosr * dy
y1prim_sq = y1prim * y1prim
rx = self.radius.real
rx_sq = rx * rx
ry = self.radius.imag
ry_sq = ry * ry
# Correct out of range radii
radius_check = (x1prim_sq / rx_sq) + (y1prim_sq / ry_sq)
if radius_check > 1:
rx *= sqrt(radius_check)
ry *= sqrt(radius_check)
rx_sq = rx * rx
ry_sq = ry * ry
t1 = rx_sq * y1prim_sq
t2 = ry_sq * x1prim_sq
c = sqrt(abs((rx_sq * ry_sq - t1 - t2) / (t1 + t2)))
if self.arc == self.sweep:
c = -c
cxprim = c * rx * y1prim / ry
cyprim = -c * ry * x1prim / rx
self.center = complex((cosr * cxprim - sinr * cyprim) +
((self.start0.real + self.end0.real) / 2),
(sinr * cxprim + cosr * cyprim) +
((self.start0.imag + self.end0.imag) / 2))
ux = (x1prim - cxprim) / rx
uy = (y1prim - cyprim) / ry
vx = (-x1prim - cxprim) / rx
vy = (-y1prim - cyprim) / ry
n = sqrt(ux * ux + uy * uy)
p = ux
theta = degrees(acos(p / n))
if uy < 0:
theta = -theta
self.theta = theta % 360
n = sqrt((ux * ux + uy * uy) * (vx * vx + vy * vy))
p = ux * vx + uy * vy
d = p/n
# In certain cases the above calculation can through inaccuracies
# become just slightly out of range, f ex -1.0000000000000002.
if d > 1.0:
d = 1.0
elif d < -1.0:
d = -1.0
delta = degrees(acos(d))
if (ux * vy - uy * vx) < 0:
delta = -delta
self.delta = delta % 360
if not self.sweep:
self.delta -= 360
def point(self, pos):
if pos == 0.:
return self.start
elif pos == 1.:
return self.end
angle = radians(self.theta + (self.delta * pos))
cosr = cos(radians(self.rotation))
sinr = sin(radians(self.rotation))
x = (cosr * cos(angle) * self.radius.real - sinr * sin(angle) *
self.radius.imag + self.center.real)
y = (sinr * cos(angle) * self.radius.real + cosr * sin(angle) *
self.radius.imag + self.center.imag)
return self.scaler(complex(x, y))
def length(self, error=ERROR, min_depth=MIN_DEPTH):
"""The length of an elliptical arc segment requires numerical
integration, and in that case it's simpler to just do a geometric
approximation, as for cubic bezier curves.
"""
start_point = self.point(0)
end_point = self.point(1)
return segment_length(self, 0, 1, start_point, end_point, error, min_depth, 0)
class SVGState(object):
def __init__(self, fill=(0.,0.,0.), fillOpacity=None, fillRule='nonzero', stroke=None, strokeOpacity=None, strokeWidth=0.1, strokeWidthScaling=True):
self.fill = fill
self.fillOpacity = fillOpacity
self.fillRule = fillRule
self.stroke = stroke
self.strokeOpacity = strokeOpacity
self.strokeWidth = strokeWidth
self.strokeWidthScaling = strokeWidthScaling
def clone(self):
return SVGState(fill=self.fill, fillOpacity=self.fillOpacity, fillRule=self.fillRule, stroke=self.stroke, strokeOpacity=self.strokeOpacity,
strokeWidth=self.strokeWidth, strokeWidthScaling=self.strokeWidthScaling)
class Path(MutableSequence):
"""A Path is a sequence of path segments"""
# Put it here, so there is a default if unpickled.
_closed = False
def __init__(self, *segments, **kw):
self._segments = list(segments)
self._length = None
self._lengths = None
if 'closed' in kw:
self.closed = kw['closed']
if 'svgState' in kw:
self.svgState = kw['svgState']
else:
self.svgState = SVGState()
def __getitem__(self, index):
return self._segments[index]
def __setitem__(self, index, value):
self._segments[index] = value
self._length = None
def __delitem__(self, index):
del self._segments[index]
self._length = None
def insert(self, index, value):
self._segments.insert(index, value)
self._length = None
def reverse(self):
# Reversing the order of a path would require reversing each element
# as well. That's not implemented.
raise NotImplementedError
def __len__(self):
return len(self._segments)
def __repr__(self):
return 'Path(%s, closed=%s)' % (
', '.join(repr(x) for x in self._segments), self.closed)
def __eq__(self, other):
if not isinstance(other, Path):
return NotImplemented
if len(self) != len(other):
return False
for s, o in zip(self._segments, other._segments):
if not s == o:
return False
return True
def __ne__(self, other):
if not isinstance(other, Path):
return NotImplemented
return not self == other
def _calc_lengths(self, error=ERROR, min_depth=MIN_DEPTH):
## TODO: check if error has decreased since last calculation
if self._length is not None:
return
lengths = [each.length(error=error, min_depth=min_depth) for each in self._segments]
self._length = sum(lengths)
self._lengths = [each / (1 if self._length==0. else self._length) for each in lengths]
def point(self, pos, error=ERROR):
# Shortcuts
if pos == 0.0:
return self._segments[0].point(pos)
if pos == 1.0:
return self._segments[-1].point(pos)
self._calc_lengths(error=error)
# Find which segment the point we search for is located on:
segment_start = 0
for index, segment in enumerate(self._segments):
segment_end = segment_start + self._lengths[index]
if segment_end >= pos:
# This is the segment! How far in on the segment is the point?
segment_pos = (pos - segment_start) / (segment_end - segment_start)
break
segment_start = segment_end
return segment.point(segment_pos)
def length(self, error=ERROR, min_depth=MIN_DEPTH):
self._calc_lengths(error, min_depth)
return self._length
def measure(self, start, end, error=ERROR, min_depth=MIN_DEPTH):
self._calc_lengths(error=error)
if start == 0.0 and end == 1.0:
return self.length()
length = 0
segment_start = 0
for index, segment in enumerate(self._segments):
if end <= segment_start:
break
segment_end = segment_start + self._lengths[index]
if start < segment_end:
# this segment intersects the part of the path we want
if start <= segment_start and segment_end <= end:
# whole segment is contained in the part of the path
length += self._lengths[index] * self._length
else:
if start <= segment_start:
start_in_segment = 0.
else:
start_in_segment = (start-segment_start)/(segment_end-segment_start)
if segment_end <= end:
end_in_segment = 1.
else:
end_in_segment = (end-segment_start)/(segment_end-segment_start)
segment = self._segments[index]
length += segment_length(segment, start_in_segment, end_in_segment, segment.point(start_in_segment),
segment.point(end_in_segment), error, MIN_DEPTH, 0)
segment_start = segment_end
return length
def _is_closable(self):
"""Returns true if the end is on the start of a segment"""
try:
end = self[-1].end
except:
return False
for segment in self:
if segment.start == end:
return True
return False
def breakup(self):
paths = []
prevEnd = None
segments = []
for segment in self._segments:
if prevEnd is None or segment.point(0.) == prevEnd:
segments.append(segment)
else:
paths.append(Path(*segments, svgState=self.svgState))
segments = [segment]
prevEnd = segment.point(1.)
if len(segments) > 0:
paths.append(Path(*segments, svgState=self.svgState))
return paths
def linearApproximation(self, error=0.001, max_depth=32):
closed = False
keepSegmentIndex = 0
if self.closed:
end = self[-1].end
for i,segment in enumerate(self):
if segment.start == end:
keepSegmentIndex = i
closed = True
break
keepSubpathIndex = 0
keepPointIndex = 0
subpaths = []
subpath = []
prevEnd = None
for i,segment in enumerate(self._segments):
if prevEnd is None or segment.start == prevEnd:
if i == keepSegmentIndex:
keepSubpathIndex = len(subpaths)
keepPointIndex = len(subpath)
else:
subpaths.append(subpath)
subpath = []
subpath += segment.getApproximatePoints(error=error/2., max_depth=max_depth)
prevEnd = segment.end
if len(subpath) > 0:
subpaths.append(subpath)
linearPath = Path(svgState=self.svgState)
for i,subpath in enumerate(subpaths):
keep = set((keepPointIndex,)) if i == keepSubpathIndex else set()
special = None
if i == keepSubpathIndex:
special = subpath[keepPointIndex]
points = removeCollinear(subpath, error=error/2., pointsToKeep=keep)
# points = subpath
for j in range(len(points)-1):
linearPath.append(Line(points[j], points[j+1]))
linearPath.closed = self.closed and linearPath._is_closable()
linearPath.svgState = self.svgState
return linearPath
def getApproximateLines(self, error=0.001, max_depth=32):
lines = []
for subpath in self.breakup():
points = subpath.getApproximatePoints(error=error, max_depth=max_depth)
for i in range(len(points)-1):
lines.append(points[i],points[i+1])
return lines
@property
def closed(self):
"""Checks that the path is closed"""
return self._closed and self._is_closable()
@closed.setter
def closed(self, value):
value = bool(value)
if value and not self._is_closable():
raise ValueError("End does not coincide with a segment start.")
self._closed = value
def d(self):
if self.closed:
segments = self[:-1]
else:
segments = self[:]
current_pos = None
parts = []
previous_segment = None
end = self[-1].end
for segment in segments:
start = segment.start
# If the start of this segment does not coincide with the end of
# the last segment or if this segment is actually the close point
# of a closed path, then we should start a new subpath here.
if current_pos != start or (self.closed and start == end):
parts.append('M {0:G},{1:G}'.format(start.real, start.imag))
if isinstance(segment, Line):
parts.append('L {0:G},{1:G}'.format(
segment.end.real, segment.end.imag)
)
elif isinstance(segment, CubicBezier):
if segment.is_smooth_from(previous_segment):
parts.append('S {0:G},{1:G} {2:G},{3:G}'.format(
segment.control2.real, segment.control2.imag,
segment.end.real, segment.end.imag)
)
else:
parts.append('C {0:G},{1:G} {2:G},{3:G} {4:G},{5:G}'.format(
segment.control1.real, segment.control1.imag,
segment.control2.real, segment.control2.imag,
segment.end.real, segment.end.imag)
)
elif isinstance(segment, QuadraticBezier):
if segment.is_smooth_from(previous_segment):
parts.append('T {0:G},{1:G}'.format(
segment.end.real, segment.end.imag)
)
else:
parts.append('Q {0:G},{1:G} {2:G},{3:G}'.format(
segment.control.real, segment.control.imag,
segment.end.real, segment.end.imag)
)
elif isinstance(segment, Arc):
parts.append('A {0:G},{1:G} {2:G} {3:d},{4:d} {5:G},{6:G}'.format(
segment.radius.real, segment.radius.imag, segment.rotation,
int(segment.arc), int(segment.sweep),
segment.end.real, segment.end.imag)
)
current_pos = segment.end
previous_segment = segment
if self.closed:
parts.append('Z')
return ' '.join(parts)