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 22:Example: 23 24 >>> t = Transform(2, 0, 0, 3, 0, 0) 25 >>> t.transformPoint((100, 100)) 26 (200, 300) 27 >>> t = Scale(2, 3) 28 >>> t.transformPoint((100, 100)) 29 (200, 300) 30 >>> t.transformPoint((0, 0)) 31 (0, 0) 32 >>> t = Offset(2, 3) 33 >>> t.transformPoint((100, 100)) 34 (102, 103) 35 >>> t.transformPoint((0, 0)) 36 (2, 3) 37 >>> t2 = t.scale(0.5) 38 >>> t2.transformPoint((100, 100)) 39 (52.0, 53.0) 40 >>> import math 41 >>> t3 = t2.rotate(math.pi / 2) 42 >>> t3.transformPoint((0, 0)) 43 (2.0, 3.0) 44 >>> t3.transformPoint((100, 100)) 45 (-48.0, 53.0) 46 >>> t = Identity.scale(0.5).translate(100, 200).skew(0.1, 0.2) 47 >>> t.transformPoints([(0, 0), (1, 1), (100, 100)]) 48 [(50.0, 100.0), (50.550167336042726, 100.60135501775433), (105.01673360427253, 160.13550177543362)] 49 >>> 50""" 51 52from typing import NamedTuple 53 54 55__all__ = ["Transform", "Identity", "Offset", "Scale"] 56 57 58_EPSILON = 1e-15 59_ONE_EPSILON = 1 - _EPSILON 60_MINUS_ONE_EPSILON = -1 + _EPSILON 61 62 63def _normSinCos(v): 64 if abs(v) < _EPSILON: 65 v = 0 66 elif v > _ONE_EPSILON: 67 v = 1 68 elif v < _MINUS_ONE_EPSILON: 69 v = -1 70 return v 71 72 73class Transform(NamedTuple): 74 75 """2x2 transformation matrix plus offset, a.k.a. Affine transform. 76 Transform instances are immutable: all transforming methods, eg. 77 rotate(), return a new Transform instance. 78 79 :Example: 80 81 >>> t = Transform() 82 >>> t 83 <Transform [1 0 0 1 0 0]> 84 >>> t.scale(2) 85 <Transform [2 0 0 2 0 0]> 86 >>> t.scale(2.5, 5.5) 87 <Transform [2.5 0 0 5.5 0 0]> 88 >>> 89 >>> t.scale(2, 3).transformPoint((100, 100)) 90 (200, 300) 91 92 Transform's constructor takes six arguments, all of which are 93 optional, and can be used as keyword arguments:: 94 95 >>> Transform(12) 96 <Transform [12 0 0 1 0 0]> 97 >>> Transform(dx=12) 98 <Transform [1 0 0 1 12 0]> 99 >>> Transform(yx=12) 100 <Transform [1 0 12 1 0 0]> 101 102 Transform instances also behave like sequences of length 6:: 103 104 >>> len(Identity) 105 6 106 >>> list(Identity) 107 [1, 0, 0, 1, 0, 0] 108 >>> tuple(Identity) 109 (1, 0, 0, 1, 0, 0) 110 111 Transform instances are comparable:: 112 113 >>> t1 = Identity.scale(2, 3).translate(4, 6) 114 >>> t2 = Identity.translate(8, 18).scale(2, 3) 115 >>> t1 == t2 116 1 117 118 But beware of floating point rounding errors:: 119 120 >>> t1 = Identity.scale(0.2, 0.3).translate(0.4, 0.6) 121 >>> t2 = Identity.translate(0.08, 0.18).scale(0.2, 0.3) 122 >>> t1 123 <Transform [0.2 0 0 0.3 0.08 0.18]> 124 >>> t2 125 <Transform [0.2 0 0 0.3 0.08 0.18]> 126 >>> t1 == t2 127 0 128 129 Transform instances are hashable, meaning you can use them as 130 keys in dictionaries:: 131 132 >>> d = {Scale(12, 13): None} 133 >>> d 134 {<Transform [12 0 0 13 0 0]>: None} 135 136 But again, beware of floating point rounding errors:: 137 138 >>> t1 = Identity.scale(0.2, 0.3).translate(0.4, 0.6) 139 >>> t2 = Identity.translate(0.08, 0.18).scale(0.2, 0.3) 140 >>> t1 141 <Transform [0.2 0 0 0.3 0.08 0.18]> 142 >>> t2 143 <Transform [0.2 0 0 0.3 0.08 0.18]> 144 >>> d = {t1: None} 145 >>> d 146 {<Transform [0.2 0 0 0.3 0.08 0.18]>: None} 147 >>> d[t2] 148 Traceback (most recent call last): 149 File "<stdin>", line 1, in ? 150 KeyError: <Transform [0.2 0 0 0.3 0.08 0.18]> 151 """ 152 153 xx: float = 1 154 xy: float = 0 155 yx: float = 0 156 yy: float = 1 157 dx: float = 0 158 dy: float = 0 159 160 def transformPoint(self, p): 161 """Transform a point. 162 163 :Example: 164 165 >>> t = Transform() 166 >>> t = t.scale(2.5, 5.5) 167 >>> t.transformPoint((100, 100)) 168 (250.0, 550.0) 169 """ 170 (x, y) = p 171 xx, xy, yx, yy, dx, dy = self 172 return (xx*x + yx*y + dx, xy*x + yy*y + dy) 173 174 def transformPoints(self, points): 175 """Transform a list of points. 176 177 :Example: 178 179 >>> t = Scale(2, 3) 180 >>> t.transformPoints([(0, 0), (0, 100), (100, 100), (100, 0)]) 181 [(0, 0), (0, 300), (200, 300), (200, 0)] 182 >>> 183 """ 184 xx, xy, yx, yy, dx, dy = self 185 return [(xx*x + yx*y + dx, xy*x + yy*y + dy) for x, y in points] 186 187 def transformVector(self, v): 188 """Transform an (dx, dy) vector, treating translation as zero. 189 190 :Example: 191 192 >>> t = Transform(2, 0, 0, 2, 10, 20) 193 >>> t.transformVector((3, -4)) 194 (6, -8) 195 >>> 196 """ 197 (dx, dy) = v 198 xx, xy, yx, yy = self[:4] 199 return (xx*dx + yx*dy, xy*dx + yy*dy) 200 201 def transformVectors(self, vectors): 202 """Transform a list of (dx, dy) vector, treating translation as zero. 203 204 :Example: 205 >>> t = Transform(2, 0, 0, 2, 10, 20) 206 >>> t.transformVectors([(3, -4), (5, -6)]) 207 [(6, -8), (10, -12)] 208 >>> 209 """ 210 xx, xy, yx, yy = self[:4] 211 return [(xx*dx + yx*dy, xy*dx + yy*dy) for dx, dy in vectors] 212 213 def translate(self, x=0, y=0): 214 """Return a new transformation, translated (offset) by x, y. 215 216 :Example: 217 >>> t = Transform() 218 >>> t.translate(20, 30) 219 <Transform [1 0 0 1 20 30]> 220 >>> 221 """ 222 return self.transform((1, 0, 0, 1, x, y)) 223 224 def scale(self, x=1, y=None): 225 """Return a new transformation, scaled by x, y. The 'y' argument 226 may be None, which implies to use the x value for y as well. 227 228 :Example: 229 >>> t = Transform() 230 >>> t.scale(5) 231 <Transform [5 0 0 5 0 0]> 232 >>> t.scale(5, 6) 233 <Transform [5 0 0 6 0 0]> 234 >>> 235 """ 236 if y is None: 237 y = x 238 return self.transform((x, 0, 0, y, 0, 0)) 239 240 def rotate(self, angle): 241 """Return a new transformation, rotated by 'angle' (radians). 242 243 :Example: 244 >>> import math 245 >>> t = Transform() 246 >>> t.rotate(math.pi / 2) 247 <Transform [0 1 -1 0 0 0]> 248 >>> 249 """ 250 import math 251 c = _normSinCos(math.cos(angle)) 252 s = _normSinCos(math.sin(angle)) 253 return self.transform((c, s, -s, c, 0, 0)) 254 255 def skew(self, x=0, y=0): 256 """Return a new transformation, skewed by x and y. 257 258 :Example: 259 >>> import math 260 >>> t = Transform() 261 >>> t.skew(math.pi / 4) 262 <Transform [1 0 1 1 0 0]> 263 >>> 264 """ 265 import math 266 return self.transform((1, math.tan(y), math.tan(x), 1, 0, 0)) 267 268 def transform(self, other): 269 """Return a new transformation, transformed by another 270 transformation. 271 272 :Example: 273 >>> t = Transform(2, 0, 0, 3, 1, 6) 274 >>> t.transform((4, 3, 2, 1, 5, 6)) 275 <Transform [8 9 4 3 11 24]> 276 >>> 277 """ 278 xx1, xy1, yx1, yy1, dx1, dy1 = other 279 xx2, xy2, yx2, yy2, dx2, dy2 = self 280 return self.__class__( 281 xx1*xx2 + xy1*yx2, 282 xx1*xy2 + xy1*yy2, 283 yx1*xx2 + yy1*yx2, 284 yx1*xy2 + yy1*yy2, 285 xx2*dx1 + yx2*dy1 + dx2, 286 xy2*dx1 + yy2*dy1 + dy2) 287 288 def reverseTransform(self, other): 289 """Return a new transformation, which is the other transformation 290 transformed by self. self.reverseTransform(other) is equivalent to 291 other.transform(self). 292 293 :Example: 294 >>> t = Transform(2, 0, 0, 3, 1, 6) 295 >>> t.reverseTransform((4, 3, 2, 1, 5, 6)) 296 <Transform [8 6 6 3 21 15]> 297 >>> Transform(4, 3, 2, 1, 5, 6).transform((2, 0, 0, 3, 1, 6)) 298 <Transform [8 6 6 3 21 15]> 299 >>> 300 """ 301 xx1, xy1, yx1, yy1, dx1, dy1 = self 302 xx2, xy2, yx2, yy2, dx2, dy2 = other 303 return self.__class__( 304 xx1*xx2 + xy1*yx2, 305 xx1*xy2 + xy1*yy2, 306 yx1*xx2 + yy1*yx2, 307 yx1*xy2 + yy1*yy2, 308 xx2*dx1 + yx2*dy1 + dx2, 309 xy2*dx1 + yy2*dy1 + dy2) 310 311 def inverse(self): 312 """Return the inverse transformation. 313 314 :Example: 315 >>> t = Identity.translate(2, 3).scale(4, 5) 316 >>> t.transformPoint((10, 20)) 317 (42, 103) 318 >>> it = t.inverse() 319 >>> it.transformPoint((42, 103)) 320 (10.0, 20.0) 321 >>> 322 """ 323 if self == Identity: 324 return self 325 xx, xy, yx, yy, dx, dy = self 326 det = xx*yy - yx*xy 327 xx, xy, yx, yy = yy/det, -xy/det, -yx/det, xx/det 328 dx, dy = -xx*dx - yx*dy, -xy*dx - yy*dy 329 return self.__class__(xx, xy, yx, yy, dx, dy) 330 331 def toPS(self): 332 """Return a PostScript representation 333 334 :Example: 335 336 >>> t = Identity.scale(2, 3).translate(4, 5) 337 >>> t.toPS() 338 '[2 0 0 3 8 15]' 339 >>> 340 """ 341 return "[%s %s %s %s %s %s]" % self 342 343 def __bool__(self): 344 """Returns True if transform is not identity, False otherwise. 345 346 :Example: 347 348 >>> bool(Identity) 349 False 350 >>> bool(Transform()) 351 False 352 >>> bool(Scale(1.)) 353 False 354 >>> bool(Scale(2)) 355 True 356 >>> bool(Offset()) 357 False 358 >>> bool(Offset(0)) 359 False 360 >>> bool(Offset(2)) 361 True 362 """ 363 return self != Identity 364 365 def __repr__(self): 366 return "<%s [%g %g %g %g %g %g]>" % ((self.__class__.__name__,) + self) 367 368 369Identity = Transform() 370 371def Offset(x=0, y=0): 372 """Return the identity transformation offset by x, y. 373 374 :Example: 375 >>> Offset(2, 3) 376 <Transform [1 0 0 1 2 3]> 377 >>> 378 """ 379 return Transform(1, 0, 0, 1, x, y) 380 381def Scale(x, y=None): 382 """Return the identity transformation scaled by x, y. The 'y' argument 383 may be None, which implies to use the x value for y as well. 384 385 :Example: 386 >>> Scale(2, 3) 387 <Transform [2 0 0 3 0 0]> 388 >>> 389 """ 390 if y is None: 391 y = x 392 return Transform(x, 0, 0, y, 0, 0) 393 394 395if __name__ == "__main__": 396 import sys 397 import doctest 398 sys.exit(doctest.testmod().failed) 399