1"""Affine 2D transformation matrix class.
2
3The Transform class implements various transformation matrix operations,
4both on the matrix itself, as well as on 2D coordinates.
5
6Transform instances are effectively immutable: all methods that operate on the
7transformation itself always return a new instance. This has as the
8interesting side effect that Transform instances are hashable, ie. they can be
9used as dictionary keys.
10
11This module exports the following symbols:
12
13Transform
14 this is the main class
15Identity
16 Transform instance set to the identity transformation
17Offset
18 Convenience function that returns a translating transformation
19Scale
20 Convenience function that returns a scaling transformation
21
22The DecomposedTransform class implements a transformation with separate
23translate, rotation, scale, skew, and transformation-center components.
24
25:Example:
26
27 >>> t = Transform(2, 0, 0, 3, 0, 0)
28 >>> t.transformPoint((100, 100))
29 (200, 300)
30 >>> t = Scale(2, 3)
31 >>> t.transformPoint((100, 100))
32 (200, 300)
33 >>> t.transformPoint((0, 0))
34 (0, 0)
35 >>> t = Offset(2, 3)
36 >>> t.transformPoint((100, 100))
37 (102, 103)
38 >>> t.transformPoint((0, 0))
39 (2, 3)
40 >>> t2 = t.scale(0.5)
41 >>> t2.transformPoint((100, 100))
42 (52.0, 53.0)
43 >>> import math
44 >>> t3 = t2.rotate(math.pi / 2)
45 >>> t3.transformPoint((0, 0))
46 (2.0, 3.0)
47 >>> t3.transformPoint((100, 100))
48 (-48.0, 53.0)
49 >>> t = Identity.scale(0.5).translate(100, 200).skew(0.1, 0.2)
50 >>> t.transformPoints([(0, 0), (1, 1), (100, 100)])
51 [(50.0, 100.0), (50.550167336042726, 100.60135501775433), (105.01673360427253, 160.13550177543362)]
52 >>>
53"""
54
55import math
56from typing import NamedTuple
57from dataclasses import dataclass
58
59
60__all__ = ["Transform", "Identity", "Offset", "Scale", "DecomposedTransform"]
61
62
63_EPSILON = 1e-15
64_ONE_EPSILON = 1 - _EPSILON
65_MINUS_ONE_EPSILON = -1 + _EPSILON
66
67
68def _normSinCos(v):
69 if abs(v) < _EPSILON:
70 v = 0
71 elif v > _ONE_EPSILON:
72 v = 1
73 elif v < _MINUS_ONE_EPSILON:
74 v = -1
75 return v
76
77
78class Transform(NamedTuple):
79 """2x2 transformation matrix plus offset, a.k.a. Affine transform.
80 Transform instances are immutable: all transforming methods, eg.
81 rotate(), return a new Transform instance.
82
83 :Example:
84
85 >>> t = Transform()
86 >>> t
87 <Transform [1 0 0 1 0 0]>
88 >>> t.scale(2)
89 <Transform [2 0 0 2 0 0]>
90 >>> t.scale(2.5, 5.5)
91 <Transform [2.5 0 0 5.5 0 0]>
92 >>>
93 >>> t.scale(2, 3).transformPoint((100, 100))
94 (200, 300)
95
96 Transform's constructor takes six arguments, all of which are
97 optional, and can be used as keyword arguments::
98
99 >>> Transform(12)
100 <Transform [12 0 0 1 0 0]>
101 >>> Transform(dx=12)
102 <Transform [1 0 0 1 12 0]>
103 >>> Transform(yx=12)
104 <Transform [1 0 12 1 0 0]>
105
106 Transform instances also behave like sequences of length 6::
107
108 >>> len(Identity)
109 6
110 >>> list(Identity)
111 [1, 0, 0, 1, 0, 0]
112 >>> tuple(Identity)
113 (1, 0, 0, 1, 0, 0)
114
115 Transform instances are comparable::
116
117 >>> t1 = Identity.scale(2, 3).translate(4, 6)
118 >>> t2 = Identity.translate(8, 18).scale(2, 3)
119 >>> t1 == t2
120 1
121
122 But beware of floating point rounding errors::
123
124 >>> t1 = Identity.scale(0.2, 0.3).translate(0.4, 0.6)
125 >>> t2 = Identity.translate(0.08, 0.18).scale(0.2, 0.3)
126 >>> t1
127 <Transform [0.2 0 0 0.3 0.08 0.18]>
128 >>> t2
129 <Transform [0.2 0 0 0.3 0.08 0.18]>
130 >>> t1 == t2
131 0
132
133 Transform instances are hashable, meaning you can use them as
134 keys in dictionaries::
135
136 >>> d = {Scale(12, 13): None}
137 >>> d
138 {<Transform [12 0 0 13 0 0]>: None}
139
140 But again, beware of floating point rounding errors::
141
142 >>> t1 = Identity.scale(0.2, 0.3).translate(0.4, 0.6)
143 >>> t2 = Identity.translate(0.08, 0.18).scale(0.2, 0.3)
144 >>> t1
145 <Transform [0.2 0 0 0.3 0.08 0.18]>
146 >>> t2
147 <Transform [0.2 0 0 0.3 0.08 0.18]>
148 >>> d = {t1: None}
149 >>> d
150 {<Transform [0.2 0 0 0.3 0.08 0.18]>: None}
151 >>> d[t2]
152 Traceback (most recent call last):
153 File "<stdin>", line 1, in ?
154 KeyError: <Transform [0.2 0 0 0.3 0.08 0.18]>
155 """
156
157 xx: float = 1
158 xy: float = 0
159 yx: float = 0
160 yy: float = 1
161 dx: float = 0
162 dy: float = 0
163
164 def transformPoint(self, p):
165 """Transform a point.
166
167 :Example:
168
169 >>> t = Transform()
170 >>> t = t.scale(2.5, 5.5)
171 >>> t.transformPoint((100, 100))
172 (250.0, 550.0)
173 """
174 (x, y) = p
175 xx, xy, yx, yy, dx, dy = self
176 return (xx * x + yx * y + dx, xy * x + yy * y + dy)
177
178 def transformPoints(self, points):
179 """Transform a list of points.
180
181 :Example:
182
183 >>> t = Scale(2, 3)
184 >>> t.transformPoints([(0, 0), (0, 100), (100, 100), (100, 0)])
185 [(0, 0), (0, 300), (200, 300), (200, 0)]
186 >>>
187 """
188 xx, xy, yx, yy, dx, dy = self
189 return [(xx * x + yx * y + dx, xy * x + yy * y + dy) for x, y in points]
190
191 def transformVector(self, v):
192 """Transform an (dx, dy) vector, treating translation as zero.
193
194 :Example:
195
196 >>> t = Transform(2, 0, 0, 2, 10, 20)
197 >>> t.transformVector((3, -4))
198 (6, -8)
199 >>>
200 """
201 (dx, dy) = v
202 xx, xy, yx, yy = self[:4]
203 return (xx * dx + yx * dy, xy * dx + yy * dy)
204
205 def transformVectors(self, vectors):
206 """Transform a list of (dx, dy) vector, treating translation as zero.
207
208 :Example:
209 >>> t = Transform(2, 0, 0, 2, 10, 20)
210 >>> t.transformVectors([(3, -4), (5, -6)])
211 [(6, -8), (10, -12)]
212 >>>
213 """
214 xx, xy, yx, yy = self[:4]
215 return [(xx * dx + yx * dy, xy * dx + yy * dy) for dx, dy in vectors]
216
217 def translate(self, x=0, y=0):
218 """Return a new transformation, translated (offset) by x, y.
219
220 :Example:
221 >>> t = Transform()
222 >>> t.translate(20, 30)
223 <Transform [1 0 0 1 20 30]>
224 >>>
225 """
226 return self.transform((1, 0, 0, 1, x, y))
227
228 def scale(self, x=1, y=None):
229 """Return a new transformation, scaled by x, y. The 'y' argument
230 may be None, which implies to use the x value for y as well.
231
232 :Example:
233 >>> t = Transform()
234 >>> t.scale(5)
235 <Transform [5 0 0 5 0 0]>
236 >>> t.scale(5, 6)
237 <Transform [5 0 0 6 0 0]>
238 >>>
239 """
240 if y is None:
241 y = x
242 return self.transform((x, 0, 0, y, 0, 0))
243
244 def rotate(self, angle):
245 """Return a new transformation, rotated by 'angle' (radians).
246
247 :Example:
248 >>> import math
249 >>> t = Transform()
250 >>> t.rotate(math.pi / 2)
251 <Transform [0 1 -1 0 0 0]>
252 >>>
253 """
254 import math
255
256 c = _normSinCos(math.cos(angle))
257 s = _normSinCos(math.sin(angle))
258 return self.transform((c, s, -s, c, 0, 0))
259
260 def skew(self, x=0, y=0):
261 """Return a new transformation, skewed by x and y.
262
263 :Example:
264 >>> import math
265 >>> t = Transform()
266 >>> t.skew(math.pi / 4)
267 <Transform [1 0 1 1 0 0]>
268 >>>
269 """
270 import math
271
272 return self.transform((1, math.tan(y), math.tan(x), 1, 0, 0))
273
274 def transform(self, other):
275 """Return a new transformation, transformed by another
276 transformation.
277
278 :Example:
279 >>> t = Transform(2, 0, 0, 3, 1, 6)
280 >>> t.transform((4, 3, 2, 1, 5, 6))
281 <Transform [8 9 4 3 11 24]>
282 >>>
283 """
284 xx1, xy1, yx1, yy1, dx1, dy1 = other
285 xx2, xy2, yx2, yy2, dx2, dy2 = self
286 return self.__class__(
287 xx1 * xx2 + xy1 * yx2,
288 xx1 * xy2 + xy1 * yy2,
289 yx1 * xx2 + yy1 * yx2,
290 yx1 * xy2 + yy1 * yy2,
291 xx2 * dx1 + yx2 * dy1 + dx2,
292 xy2 * dx1 + yy2 * dy1 + dy2,
293 )
294
295 def reverseTransform(self, other):
296 """Return a new transformation, which is the other transformation
297 transformed by self. self.reverseTransform(other) is equivalent to
298 other.transform(self).
299
300 :Example:
301 >>> t = Transform(2, 0, 0, 3, 1, 6)
302 >>> t.reverseTransform((4, 3, 2, 1, 5, 6))
303 <Transform [8 6 6 3 21 15]>
304 >>> Transform(4, 3, 2, 1, 5, 6).transform((2, 0, 0, 3, 1, 6))
305 <Transform [8 6 6 3 21 15]>
306 >>>
307 """
308 xx1, xy1, yx1, yy1, dx1, dy1 = self
309 xx2, xy2, yx2, yy2, dx2, dy2 = other
310 return self.__class__(
311 xx1 * xx2 + xy1 * yx2,
312 xx1 * xy2 + xy1 * yy2,
313 yx1 * xx2 + yy1 * yx2,
314 yx1 * xy2 + yy1 * yy2,
315 xx2 * dx1 + yx2 * dy1 + dx2,
316 xy2 * dx1 + yy2 * dy1 + dy2,
317 )
318
319 def inverse(self):
320 """Return the inverse transformation.
321
322 :Example:
323 >>> t = Identity.translate(2, 3).scale(4, 5)
324 >>> t.transformPoint((10, 20))
325 (42, 103)
326 >>> it = t.inverse()
327 >>> it.transformPoint((42, 103))
328 (10.0, 20.0)
329 >>>
330 """
331 if self == Identity:
332 return self
333 xx, xy, yx, yy, dx, dy = self
334 det = xx * yy - yx * xy
335 xx, xy, yx, yy = yy / det, -xy / det, -yx / det, xx / det
336 dx, dy = -xx * dx - yx * dy, -xy * dx - yy * dy
337 return self.__class__(xx, xy, yx, yy, dx, dy)
338
339 def toPS(self):
340 """Return a PostScript representation
341
342 :Example:
343
344 >>> t = Identity.scale(2, 3).translate(4, 5)
345 >>> t.toPS()
346 '[2 0 0 3 8 15]'
347 >>>
348 """
349 return "[%s %s %s %s %s %s]" % self
350
351 def toDecomposed(self) -> "DecomposedTransform":
352 """Decompose into a DecomposedTransform."""
353 return DecomposedTransform.fromTransform(self)
354
355 def __bool__(self):
356 """Returns True if transform is not identity, False otherwise.
357
358 :Example:
359
360 >>> bool(Identity)
361 False
362 >>> bool(Transform())
363 False
364 >>> bool(Scale(1.))
365 False
366 >>> bool(Scale(2))
367 True
368 >>> bool(Offset())
369 False
370 >>> bool(Offset(0))
371 False
372 >>> bool(Offset(2))
373 True
374 """
375 return self != Identity
376
377 def __repr__(self):
378 return "<%s [%g %g %g %g %g %g]>" % ((self.__class__.__name__,) + self)
379
380
381Identity = Transform()
382
383
384def Offset(x=0, y=0):
385 """Return the identity transformation offset by x, y.
386
387 :Example:
388 >>> Offset(2, 3)
389 <Transform [1 0 0 1 2 3]>
390 >>>
391 """
392 return Transform(1, 0, 0, 1, x, y)
393
394
395def Scale(x, y=None):
396 """Return the identity transformation scaled by x, y. The 'y' argument
397 may be None, which implies to use the x value for y as well.
398
399 :Example:
400 >>> Scale(2, 3)
401 <Transform [2 0 0 3 0 0]>
402 >>>
403 """
404 if y is None:
405 y = x
406 return Transform(x, 0, 0, y, 0, 0)
407
408
409@dataclass
410class DecomposedTransform:
411 """The DecomposedTransform class implements a transformation with separate
412 translate, rotation, scale, skew, and transformation-center components.
413 """
414
415 translateX: float = 0
416 translateY: float = 0
417 rotation: float = 0 # in degrees, counter-clockwise
418 scaleX: float = 1
419 scaleY: float = 1
420 skewX: float = 0 # in degrees, clockwise
421 skewY: float = 0 # in degrees, counter-clockwise
422 tCenterX: float = 0
423 tCenterY: float = 0
424
425 def __bool__(self):
426 return (
427 self.translateX != 0
428 or self.translateY != 0
429 or self.rotation != 0
430 or self.scaleX != 1
431 or self.scaleY != 1
432 or self.skewX != 0
433 or self.skewY != 0
434 or self.tCenterX != 0
435 or self.tCenterY != 0
436 )
437
438 @classmethod
439 def fromTransform(self, transform):
440 # Adapted from an answer on
441 # https://math.stackexchange.com/questions/13150/extracting-rotation-scale-values-from-2d-transformation-matrix
442 a, b, c, d, x, y = transform
443
444 sx = math.copysign(1, a)
445 if sx < 0:
446 a *= sx
447 b *= sx
448
449 delta = a * d - b * c
450
451 rotation = 0
452 scaleX = scaleY = 0
453 skewX = skewY = 0
454
455 # Apply the QR-like decomposition.
456 if a != 0 or b != 0:
457 r = math.sqrt(a * a + b * b)
458 rotation = math.acos(a / r) if b >= 0 else -math.acos(a / r)
459 scaleX, scaleY = (r, delta / r)
460 skewX, skewY = (math.atan((a * c + b * d) / (r * r)), 0)
461 elif c != 0 or d != 0:
462 s = math.sqrt(c * c + d * d)
463 rotation = math.pi / 2 - (
464 math.acos(-c / s) if d >= 0 else -math.acos(c / s)
465 )
466 scaleX, scaleY = (delta / s, s)
467 skewX, skewY = (0, math.atan((a * c + b * d) / (s * s)))
468 else:
469 # a = b = c = d = 0
470 pass
471
472 return DecomposedTransform(
473 x,
474 y,
475 math.degrees(rotation),
476 scaleX * sx,
477 scaleY,
478 math.degrees(skewX) * sx,
479 math.degrees(skewY),
480 0,
481 0,
482 )
483
484 def toTransform(self):
485 """Return the Transform() equivalent of this transformation.
486
487 :Example:
488 >>> DecomposedTransform(scaleX=2, scaleY=2).toTransform()
489 <Transform [2 0 0 2 0 0]>
490 >>>
491 """
492 t = Transform()
493 t = t.translate(
494 self.translateX + self.tCenterX, self.translateY + self.tCenterY
495 )
496 t = t.rotate(math.radians(self.rotation))
497 t = t.scale(self.scaleX, self.scaleY)
498 t = t.skew(math.radians(self.skewX), math.radians(self.skewY))
499 t = t.translate(-self.tCenterX, -self.tCenterY)
500 return t
501
502
503if __name__ == "__main__":
504 import sys
505 import doctest
506
507 sys.exit(doctest.testmod().failed)