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 @classmethod 426 def fromTransform(self, transform): 427 # Adapted from an answer on 428 # https://math.stackexchange.com/questions/13150/extracting-rotation-scale-values-from-2d-transformation-matrix 429 a, b, c, d, x, y = transform 430 431 sx = math.copysign(1, a) 432 if sx < 0: 433 a *= sx 434 b *= sx 435 436 delta = a * d - b * c 437 438 rotation = 0 439 scaleX = scaleY = 0 440 skewX = skewY = 0 441 442 # Apply the QR-like decomposition. 443 if a != 0 or b != 0: 444 r = math.sqrt(a * a + b * b) 445 rotation = math.acos(a / r) if b >= 0 else -math.acos(a / r) 446 scaleX, scaleY = (r, delta / r) 447 skewX, skewY = (math.atan((a * c + b * d) / (r * r)), 0) 448 elif c != 0 or d != 0: 449 s = math.sqrt(c * c + d * d) 450 rotation = math.pi / 2 - ( 451 math.acos(-c / s) if d >= 0 else -math.acos(c / s) 452 ) 453 scaleX, scaleY = (delta / s, s) 454 skewX, skewY = (0, math.atan((a * c + b * d) / (s * s))) 455 else: 456 # a = b = c = d = 0 457 pass 458 459 return DecomposedTransform( 460 x, 461 y, 462 math.degrees(rotation), 463 scaleX * sx, 464 scaleY, 465 math.degrees(skewX) * sx, 466 math.degrees(skewY), 467 0, 468 0, 469 ) 470 471 def toTransform(self): 472 """Return the Transform() equivalent of this transformation. 473 474 :Example: 475 >>> DecomposedTransform(scaleX=2, scaleY=2).toTransform() 476 <Transform [2 0 0 2 0 0]> 477 >>> 478 """ 479 t = Transform() 480 t = t.translate( 481 self.translateX + self.tCenterX, self.translateY + self.tCenterY 482 ) 483 t = t.rotate(math.radians(self.rotation)) 484 t = t.scale(self.scaleX, self.scaleY) 485 t = t.skew(math.radians(self.skewX), math.radians(self.skewY)) 486 t = t.translate(-self.tCenterX, -self.tCenterY) 487 return t 488 489 490if __name__ == "__main__": 491 import sys 492 import doctest 493 494 sys.exit(doctest.testmod().failed) 495