"""Affine 2D transformation matrix class. The Transform class implements various transformation matrix operations, both on the matrix itself, as well as on 2D coordinates. Transform instances are effectively immutable: all methods that operate on the transformation itself always return a new instance. This has as the interesting side effect that Transform instances are hashable, ie. they can be used as dictionary keys. This module exports the following symbols: Transform this is the main class Identity Transform instance set to the identity transformation Offset Convenience function that returns a translating transformation Scale Convenience function that returns a scaling transformation The DecomposedTransform class implements a transformation with separate translate, rotation, scale, skew, and transformation-center components. :Example: >>> t = Transform(2, 0, 0, 3, 0, 0) >>> t.transformPoint((100, 100)) (200, 300) >>> t = Scale(2, 3) >>> t.transformPoint((100, 100)) (200, 300) >>> t.transformPoint((0, 0)) (0, 0) >>> t = Offset(2, 3) >>> t.transformPoint((100, 100)) (102, 103) >>> t.transformPoint((0, 0)) (2, 3) >>> t2 = t.scale(0.5) >>> t2.transformPoint((100, 100)) (52.0, 53.0) >>> import math >>> t3 = t2.rotate(math.pi / 2) >>> t3.transformPoint((0, 0)) (2.0, 3.0) >>> t3.transformPoint((100, 100)) (-48.0, 53.0) >>> t = Identity.scale(0.5).translate(100, 200).skew(0.1, 0.2) >>> t.transformPoints([(0, 0), (1, 1), (100, 100)]) [(50.0, 100.0), (50.550167336042726, 100.60135501775433), (105.01673360427253, 160.13550177543362)] >>> """ import math from typing import NamedTuple from dataclasses import dataclass __all__ = ["Transform", "Identity", "Offset", "Scale", "DecomposedTransform"] _EPSILON = 1e-15 _ONE_EPSILON = 1 - _EPSILON _MINUS_ONE_EPSILON = -1 + _EPSILON def _normSinCos(v): if abs(v) < _EPSILON: v = 0 elif v > _ONE_EPSILON: v = 1 elif v < _MINUS_ONE_EPSILON: v = -1 return v class Transform(NamedTuple): """2x2 transformation matrix plus offset, a.k.a. Affine transform. Transform instances are immutable: all transforming methods, eg. rotate(), return a new Transform instance. :Example: >>> t = Transform() >>> t >>> t.scale(2) >>> t.scale(2.5, 5.5) >>> >>> t.scale(2, 3).transformPoint((100, 100)) (200, 300) Transform's constructor takes six arguments, all of which are optional, and can be used as keyword arguments:: >>> Transform(12) >>> Transform(dx=12) >>> Transform(yx=12) Transform instances also behave like sequences of length 6:: >>> len(Identity) 6 >>> list(Identity) [1, 0, 0, 1, 0, 0] >>> tuple(Identity) (1, 0, 0, 1, 0, 0) Transform instances are comparable:: >>> t1 = Identity.scale(2, 3).translate(4, 6) >>> t2 = Identity.translate(8, 18).scale(2, 3) >>> t1 == t2 1 But beware of floating point rounding errors:: >>> t1 = Identity.scale(0.2, 0.3).translate(0.4, 0.6) >>> t2 = Identity.translate(0.08, 0.18).scale(0.2, 0.3) >>> t1 >>> t2 >>> t1 == t2 0 Transform instances are hashable, meaning you can use them as keys in dictionaries:: >>> d = {Scale(12, 13): None} >>> d {: None} But again, beware of floating point rounding errors:: >>> t1 = Identity.scale(0.2, 0.3).translate(0.4, 0.6) >>> t2 = Identity.translate(0.08, 0.18).scale(0.2, 0.3) >>> t1 >>> t2 >>> d = {t1: None} >>> d {: None} >>> d[t2] Traceback (most recent call last): File "", line 1, in ? KeyError: """ xx: float = 1 xy: float = 0 yx: float = 0 yy: float = 1 dx: float = 0 dy: float = 0 def transformPoint(self, p): """Transform a point. :Example: >>> t = Transform() >>> t = t.scale(2.5, 5.5) >>> t.transformPoint((100, 100)) (250.0, 550.0) """ (x, y) = p xx, xy, yx, yy, dx, dy = self return (xx * x + yx * y + dx, xy * x + yy * y + dy) def transformPoints(self, points): """Transform a list of points. :Example: >>> t = Scale(2, 3) >>> t.transformPoints([(0, 0), (0, 100), (100, 100), (100, 0)]) [(0, 0), (0, 300), (200, 300), (200, 0)] >>> """ xx, xy, yx, yy, dx, dy = self return [(xx * x + yx * y + dx, xy * x + yy * y + dy) for x, y in points] def transformVector(self, v): """Transform an (dx, dy) vector, treating translation as zero. :Example: >>> t = Transform(2, 0, 0, 2, 10, 20) >>> t.transformVector((3, -4)) (6, -8) >>> """ (dx, dy) = v xx, xy, yx, yy = self[:4] return (xx * dx + yx * dy, xy * dx + yy * dy) def transformVectors(self, vectors): """Transform a list of (dx, dy) vector, treating translation as zero. :Example: >>> t = Transform(2, 0, 0, 2, 10, 20) >>> t.transformVectors([(3, -4), (5, -6)]) [(6, -8), (10, -12)] >>> """ xx, xy, yx, yy = self[:4] return [(xx * dx + yx * dy, xy * dx + yy * dy) for dx, dy in vectors] def translate(self, x=0, y=0): """Return a new transformation, translated (offset) by x, y. :Example: >>> t = Transform() >>> t.translate(20, 30) >>> """ return self.transform((1, 0, 0, 1, x, y)) def scale(self, x=1, y=None): """Return a new transformation, scaled by x, y. The 'y' argument may be None, which implies to use the x value for y as well. :Example: >>> t = Transform() >>> t.scale(5) >>> t.scale(5, 6) >>> """ if y is None: y = x return self.transform((x, 0, 0, y, 0, 0)) def rotate(self, angle): """Return a new transformation, rotated by 'angle' (radians). :Example: >>> import math >>> t = Transform() >>> t.rotate(math.pi / 2) >>> """ import math c = _normSinCos(math.cos(angle)) s = _normSinCos(math.sin(angle)) return self.transform((c, s, -s, c, 0, 0)) def skew(self, x=0, y=0): """Return a new transformation, skewed by x and y. :Example: >>> import math >>> t = Transform() >>> t.skew(math.pi / 4) >>> """ import math return self.transform((1, math.tan(y), math.tan(x), 1, 0, 0)) def transform(self, other): """Return a new transformation, transformed by another transformation. :Example: >>> t = Transform(2, 0, 0, 3, 1, 6) >>> t.transform((4, 3, 2, 1, 5, 6)) >>> """ xx1, xy1, yx1, yy1, dx1, dy1 = other xx2, xy2, yx2, yy2, dx2, dy2 = self return self.__class__( xx1 * xx2 + xy1 * yx2, xx1 * xy2 + xy1 * yy2, yx1 * xx2 + yy1 * yx2, yx1 * xy2 + yy1 * yy2, xx2 * dx1 + yx2 * dy1 + dx2, xy2 * dx1 + yy2 * dy1 + dy2, ) def reverseTransform(self, other): """Return a new transformation, which is the other transformation transformed by self. self.reverseTransform(other) is equivalent to other.transform(self). :Example: >>> t = Transform(2, 0, 0, 3, 1, 6) >>> t.reverseTransform((4, 3, 2, 1, 5, 6)) >>> Transform(4, 3, 2, 1, 5, 6).transform((2, 0, 0, 3, 1, 6)) >>> """ xx1, xy1, yx1, yy1, dx1, dy1 = self xx2, xy2, yx2, yy2, dx2, dy2 = other return self.__class__( xx1 * xx2 + xy1 * yx2, xx1 * xy2 + xy1 * yy2, yx1 * xx2 + yy1 * yx2, yx1 * xy2 + yy1 * yy2, xx2 * dx1 + yx2 * dy1 + dx2, xy2 * dx1 + yy2 * dy1 + dy2, ) def inverse(self): """Return the inverse transformation. :Example: >>> t = Identity.translate(2, 3).scale(4, 5) >>> t.transformPoint((10, 20)) (42, 103) >>> it = t.inverse() >>> it.transformPoint((42, 103)) (10.0, 20.0) >>> """ if self == Identity: return self xx, xy, yx, yy, dx, dy = self det = xx * yy - yx * xy xx, xy, yx, yy = yy / det, -xy / det, -yx / det, xx / det dx, dy = -xx * dx - yx * dy, -xy * dx - yy * dy return self.__class__(xx, xy, yx, yy, dx, dy) def toPS(self): """Return a PostScript representation :Example: >>> t = Identity.scale(2, 3).translate(4, 5) >>> t.toPS() '[2 0 0 3 8 15]' >>> """ return "[%s %s %s %s %s %s]" % self def toDecomposed(self) -> "DecomposedTransform": """Decompose into a DecomposedTransform.""" return DecomposedTransform.fromTransform(self) def __bool__(self): """Returns True if transform is not identity, False otherwise. :Example: >>> bool(Identity) False >>> bool(Transform()) False >>> bool(Scale(1.)) False >>> bool(Scale(2)) True >>> bool(Offset()) False >>> bool(Offset(0)) False >>> bool(Offset(2)) True """ return self != Identity def __repr__(self): return "<%s [%g %g %g %g %g %g]>" % ((self.__class__.__name__,) + self) Identity = Transform() def Offset(x=0, y=0): """Return the identity transformation offset by x, y. :Example: >>> Offset(2, 3) >>> """ return Transform(1, 0, 0, 1, x, y) def Scale(x, y=None): """Return the identity transformation scaled by x, y. The 'y' argument may be None, which implies to use the x value for y as well. :Example: >>> Scale(2, 3) >>> """ if y is None: y = x return Transform(x, 0, 0, y, 0, 0) @dataclass class DecomposedTransform: """The DecomposedTransform class implements a transformation with separate translate, rotation, scale, skew, and transformation-center components. """ translateX: float = 0 translateY: float = 0 rotation: float = 0 # in degrees, counter-clockwise scaleX: float = 1 scaleY: float = 1 skewX: float = 0 # in degrees, clockwise skewY: float = 0 # in degrees, counter-clockwise tCenterX: float = 0 tCenterY: float = 0 def __bool__(self): return ( self.translateX != 0 or self.translateY != 0 or self.rotation != 0 or self.scaleX != 1 or self.scaleY != 1 or self.skewX != 0 or self.skewY != 0 or self.tCenterX != 0 or self.tCenterY != 0 ) @classmethod def fromTransform(self, transform): # Adapted from an answer on # https://math.stackexchange.com/questions/13150/extracting-rotation-scale-values-from-2d-transformation-matrix a, b, c, d, x, y = transform sx = math.copysign(1, a) if sx < 0: a *= sx b *= sx delta = a * d - b * c rotation = 0 scaleX = scaleY = 0 skewX = skewY = 0 # Apply the QR-like decomposition. if a != 0 or b != 0: r = math.sqrt(a * a + b * b) rotation = math.acos(a / r) if b >= 0 else -math.acos(a / r) scaleX, scaleY = (r, delta / r) skewX, skewY = (math.atan((a * c + b * d) / (r * r)), 0) elif c != 0 or d != 0: s = math.sqrt(c * c + d * d) rotation = math.pi / 2 - ( math.acos(-c / s) if d >= 0 else -math.acos(c / s) ) scaleX, scaleY = (delta / s, s) skewX, skewY = (0, math.atan((a * c + b * d) / (s * s))) else: # a = b = c = d = 0 pass return DecomposedTransform( x, y, math.degrees(rotation), scaleX * sx, scaleY, math.degrees(skewX) * sx, math.degrees(skewY), 0, 0, ) def toTransform(self): """Return the Transform() equivalent of this transformation. :Example: >>> DecomposedTransform(scaleX=2, scaleY=2).toTransform() >>> """ t = Transform() t = t.translate( self.translateX + self.tCenterX, self.translateY + self.tCenterY ) t = t.rotate(math.radians(self.rotation)) t = t.scale(self.scaleX, self.scaleY) t = t.skew(math.radians(self.skewX), math.radians(self.skewY)) t = t.translate(-self.tCenterX, -self.tCenterY) return t if __name__ == "__main__": import sys import doctest sys.exit(doctest.testmod().failed)