|
1 # Copyright (C) 2013 -- CJlano < cjlano @ free.fr > |
|
2 |
|
3 # This program is free software; you can redistribute it and/or modify |
|
4 # it under the terms of the GNU General Public License as published by |
|
5 # the Free Software Foundation; either version 2 of the License, or |
|
6 # (at your option) any later version. |
|
7 # |
|
8 # This program is distributed in the hope that it will be useful, |
|
9 # but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
11 # GNU General Public License for more details. |
|
12 # |
|
13 # You should have received a copy of the GNU General Public License along |
|
14 # with this program; if not, write to the Free Software Foundation, Inc., |
|
15 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. |
|
16 |
|
17 ''' |
|
18 This module contains all the geometric classes and functions not directly |
|
19 related to SVG parsing. It can be reused outside the scope of SVG. |
|
20 ''' |
|
21 |
|
22 import math |
|
23 import numbers |
|
24 import operator |
|
25 |
|
26 class Point: |
|
27 def __init__(self, x=None, y=None): |
|
28 '''A Point is defined either by a tuple/list of length 2 or |
|
29 by 2 coordinates |
|
30 >>> Point(1,2) |
|
31 (1.000,2.000) |
|
32 >>> Point((1,2)) |
|
33 (1.000,2.000) |
|
34 >>> Point([1,2]) |
|
35 (1.000,2.000) |
|
36 >>> Point('1', '2') |
|
37 (1.000,2.000) |
|
38 >>> Point(('1', None)) |
|
39 (1.000,0.000) |
|
40 ''' |
|
41 if (isinstance(x, tuple) or isinstance(x, list)) and len(x) == 2: |
|
42 x,y = x |
|
43 |
|
44 # Handle empty parameter(s) which should be interpreted as 0 |
|
45 if x is None: x = 0 |
|
46 if y is None: y = 0 |
|
47 |
|
48 try: |
|
49 self.x = float(x) |
|
50 self.y = float(y) |
|
51 except: |
|
52 raise TypeError("A Point is defined by 2 numbers or a tuple") |
|
53 |
|
54 def __add__(self, other): |
|
55 '''Add 2 points by adding coordinates. |
|
56 Try to convert other to Point if necessary |
|
57 >>> Point(1,2) + Point(3,2) |
|
58 (4.000,4.000) |
|
59 >>> Point(1,2) + (3,2) |
|
60 (4.000,4.000)''' |
|
61 if not isinstance(other, Point): |
|
62 try: other = Point(other) |
|
63 except: return NotImplemented |
|
64 return Point(self.x + other.x, self.y + other.y) |
|
65 |
|
66 def __sub__(self, other): |
|
67 '''Substract two Points. |
|
68 >>> Point(1,2) - Point(3,2) |
|
69 (-2.000,0.000) |
|
70 ''' |
|
71 if not isinstance(other, Point): |
|
72 try: other = Point(other) |
|
73 except: return NotImplemented |
|
74 return Point(self.x - other.x, self.y - other.y) |
|
75 |
|
76 def __mul__(self, other): |
|
77 '''Multiply a Point with a constant. |
|
78 >>> 2 * Point(1,2) |
|
79 (2.000,4.000) |
|
80 >>> Point(1,2) * Point(1,2) #doctest:+IGNORE_EXCEPTION_DETAIL |
|
81 Traceback (most recent call last): |
|
82 ... |
|
83 TypeError: |
|
84 ''' |
|
85 if not isinstance(other, numbers.Real): |
|
86 return NotImplemented |
|
87 return Point(self.x * other, self.y * other) |
|
88 def __rmul__(self, other): |
|
89 return self.__mul__(other) |
|
90 |
|
91 def __eq__(self, other): |
|
92 '''Test equality |
|
93 >>> Point(1,2) == (1,2) |
|
94 True |
|
95 >>> Point(1,2) == Point(2,1) |
|
96 False |
|
97 ''' |
|
98 if not isinstance(other, Point): |
|
99 try: other = Point(other) |
|
100 except: return NotImplemented |
|
101 return (self.x == other.x) and (self.y == other.y) |
|
102 |
|
103 def __repr__(self): |
|
104 return '(' + format(self.x,'.3f') + ',' + format( self.y,'.3f') + ')' |
|
105 |
|
106 def __str__(self): |
|
107 return self.__repr__(); |
|
108 |
|
109 def coord(self): |
|
110 '''Return the point tuple (x,y)''' |
|
111 return (self.x, self.y) |
|
112 |
|
113 def length(self): |
|
114 '''Vector length, Pythagoras theorem''' |
|
115 return math.sqrt(self.x ** 2 + self.y ** 2) |
|
116 |
|
117 def rot(self, angle): |
|
118 '''Rotate vector [Origin,self] ''' |
|
119 if not isinstance(angle, Angle): |
|
120 try: angle = Angle(angle) |
|
121 except: return NotImplemented |
|
122 x = self.x * angle.cos - self.y * angle.sin |
|
123 y = self.x * angle.sin + self.y * angle.cos |
|
124 return Point(x,y) |
|
125 |
|
126 |
|
127 class Angle: |
|
128 '''Define a trigonometric angle [of a vector] ''' |
|
129 def __init__(self, arg): |
|
130 if isinstance(arg, numbers.Real): |
|
131 # We precompute sin and cos for rotations |
|
132 self.angle = arg |
|
133 self.cos = math.cos(self.angle) |
|
134 self.sin = math.sin(self.angle) |
|
135 elif isinstance(arg, Point): |
|
136 # Point angle is the trigonometric angle of the vector [origin, Point] |
|
137 pt = arg |
|
138 try: |
|
139 self.cos = pt.x/pt.length() |
|
140 self.sin = pt.y/pt.length() |
|
141 except ZeroDivisionError: |
|
142 self.cos = 1 |
|
143 self.sin = 0 |
|
144 |
|
145 self.angle = math.acos(self.cos) |
|
146 if self.sin < 0: |
|
147 self.angle = -self.angle |
|
148 else: |
|
149 raise TypeError("Angle is defined by a number or a Point") |
|
150 |
|
151 def __neg__(self): |
|
152 return Angle(Point(self.cos, -self.sin)) |
|
153 |
|
154 class Segment: |
|
155 '''A segment is an object defined by 2 points''' |
|
156 def __init__(self, start, end): |
|
157 self.start = start |
|
158 self.end = end |
|
159 |
|
160 def __str__(self): |
|
161 return 'Segment from ' + str(self.start) + ' to ' + str(self.end) |
|
162 |
|
163 def segments(self, precision=0): |
|
164 ''' Segments is simply the segment start -> end''' |
|
165 return [self.start, self.end] |
|
166 |
|
167 def length(self): |
|
168 '''Segment length, Pythagoras theorem''' |
|
169 s = self.end - self.start |
|
170 return math.sqrt(s.x ** 2 + s.y ** 2) |
|
171 |
|
172 def pdistance(self, p): |
|
173 '''Perpendicular distance between this Segment and a given Point p''' |
|
174 if not isinstance(p, Point): |
|
175 return NotImplemented |
|
176 |
|
177 if self.start == self.end: |
|
178 # Distance from a Point to another Point is length of a segment |
|
179 return Segment(self.start, p).length() |
|
180 |
|
181 s = self.end - self.start |
|
182 if s.x == 0: |
|
183 # Vertical Segment => pdistance is the difference of abscissa |
|
184 return abs(self.start.x - p.x) |
|
185 else: |
|
186 # That's 2-D perpendicular distance formulae (ref: Wikipedia) |
|
187 slope = s.y/s.x |
|
188 # intercept: Crossing with ordinate y-axis |
|
189 intercept = self.start.y - (slope * self.start.x) |
|
190 return abs(slope * p.x - p.y + intercept) / math.sqrt(slope ** 2 + 1) |
|
191 |
|
192 |
|
193 def bbox(self): |
|
194 xmin = min(self.start.x, self.end.x) |
|
195 xmax = max(self.start.x, self.end.x) |
|
196 ymin = min(self.start.y, self.end.y) |
|
197 ymax = max(self.start.y, self.end.y) |
|
198 |
|
199 return (Point(xmin,ymin),Point(xmax,ymax)) |
|
200 |
|
201 def transform(self, matrix): |
|
202 self.start = matrix * self.start |
|
203 self.end = matrix * self.end |
|
204 |
|
205 def scale(self, ratio): |
|
206 self.start *= ratio |
|
207 self.end *= ratio |
|
208 def translate(self, offset): |
|
209 self.start += offset |
|
210 self.end += offset |
|
211 def rotate(self, angle): |
|
212 self.start = self.start.rot(angle) |
|
213 self.end = self.end.rot(angle) |
|
214 |
|
215 class Bezier: |
|
216 '''Bezier curve class |
|
217 A Bezier curve is defined by its control points |
|
218 Its dimension is equal to the number of control points |
|
219 Note that SVG only support dimension 3 and 4 Bezier curve, respectively |
|
220 Quadratic and Cubic Bezier curve''' |
|
221 def __init__(self, pts): |
|
222 self.pts = list(pts) |
|
223 self.dimension = len(pts) |
|
224 |
|
225 def __str__(self): |
|
226 return 'Bezier' + str(self.dimension) + \ |
|
227 ' : ' + ", ".join([str(x) for x in self.pts]) |
|
228 |
|
229 def control_point(self, n): |
|
230 if n >= self.dimension: |
|
231 raise LookupError('Index is larger than Bezier curve dimension') |
|
232 else: |
|
233 return self.pts[n] |
|
234 |
|
235 def rlength(self): |
|
236 '''Rough Bezier length: length of control point segments''' |
|
237 pts = list(self.pts) |
|
238 l = 0.0 |
|
239 p1 = pts.pop() |
|
240 while pts: |
|
241 p2 = pts.pop() |
|
242 l += Segment(p1, p2).length() |
|
243 p1 = p2 |
|
244 return l |
|
245 |
|
246 def bbox(self): |
|
247 return self.rbbox() |
|
248 |
|
249 def rbbox(self): |
|
250 '''Rough bounding box: return the bounding box (P1,P2) of the Bezier |
|
251 _control_ points''' |
|
252 xmin = min([p.x for p in self.pts]) |
|
253 xmax = max([p.x for p in self.pts]) |
|
254 ymin = min([p.y for p in self.pts]) |
|
255 ymax = max([p.y for p in self.pts]) |
|
256 |
|
257 return (Point(xmin,ymin), Point(xmax,ymax)) |
|
258 |
|
259 def segments(self, precision=0): |
|
260 '''Return a polyline approximation ("segments") of the Bezier curve |
|
261 precision is the minimum significative length of a segment''' |
|
262 segments = [] |
|
263 # n is the number of Bezier points to draw according to precision |
|
264 if precision != 0: |
|
265 n = int(self.rlength() / precision) + 1 |
|
266 else: |
|
267 n = 1000 |
|
268 if n < 10: n = 10 |
|
269 if n > 1000 : n = 1000 |
|
270 |
|
271 for t in range(0, n+1): |
|
272 segments.append(self._bezierN(float(t)/n)) |
|
273 return segments |
|
274 |
|
275 def _bezier1(self, p0, p1, t): |
|
276 '''Bezier curve, one dimension |
|
277 Compute the Point corresponding to a linear Bezier curve between |
|
278 p0 and p1 at "time" t ''' |
|
279 pt = p0 + t * (p1 - p0) |
|
280 return pt |
|
281 |
|
282 def _bezierN(self, t): |
|
283 '''Bezier curve, Nth dimension |
|
284 Compute the point of the Nth dimension Bezier curve at "time" t''' |
|
285 # We reduce the N Bezier control points by computing the linear Bezier |
|
286 # point of each control point segment, creating N-1 control points |
|
287 # until we reach one single point |
|
288 res = list(self.pts) |
|
289 # We store the resulting Bezier points in res[], recursively |
|
290 for n in range(self.dimension, 1, -1): |
|
291 # For each control point of nth dimension, |
|
292 # compute linear Bezier point a t |
|
293 for i in range(0,n-1): |
|
294 res[i] = self._bezier1(res[i], res[i+1], t) |
|
295 return res[0] |
|
296 |
|
297 def transform(self, matrix): |
|
298 self.pts = [matrix * x for x in self.pts] |
|
299 |
|
300 def scale(self, ratio): |
|
301 self.pts = [x * ratio for x in self.pts] |
|
302 def translate(self, offset): |
|
303 self.pts = [x + offset for x in self.pts] |
|
304 def rotate(self, angle): |
|
305 self.pts = [x.rot(angle) for x in self.pts] |
|
306 |
|
307 class MoveTo: |
|
308 def __init__(self, dest): |
|
309 self.dest = dest |
|
310 |
|
311 def bbox(self): |
|
312 return (self.dest, self.dest) |
|
313 |
|
314 def transform(self, matrix): |
|
315 self.dest = matrix * self.dest |
|
316 |
|
317 def scale(self, ratio): |
|
318 self.dest *= ratio |
|
319 def translate(self, offset): |
|
320 self.dest += offset |
|
321 def rotate(self, angle): |
|
322 self.dest = self.dest.rot(angle) |
|
323 |
|
324 |
|
325 def simplify_segment(segment, epsilon): |
|
326 '''Ramer-Douglas-Peucker algorithm''' |
|
327 if len(segment) < 3 or epsilon <= 0: |
|
328 return segment[:] |
|
329 |
|
330 l = Segment(segment[0], segment[-1]) # Longest segment |
|
331 |
|
332 # Find the furthest point from the segment |
|
333 index, maxDist = max([(i, l.pdistance(p)) for i,p in enumerate(segment)], |
|
334 key=operator.itemgetter(1)) |
|
335 |
|
336 if maxDist > epsilon: |
|
337 # Recursively call with segment splited in 2 on its furthest point |
|
338 r1 = simplify_segment(segment[:index+1], epsilon) |
|
339 r2 = simplify_segment(segment[index:], epsilon) |
|
340 # Remove redundant 'middle' Point |
|
341 return r1[:-1] + r2 |
|
342 else: |
|
343 return [segment[0], segment[-1]] |