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