| """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 |
| |
| :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)] |
| >>> |
| """ |
| |
| from typing import NamedTuple |
| |
| |
| __all__ = ["Transform", "Identity", "Offset", "Scale"] |
| |
| |
| _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 |
| <Transform [1 0 0 1 0 0]> |
| >>> t.scale(2) |
| <Transform [2 0 0 2 0 0]> |
| >>> t.scale(2.5, 5.5) |
| <Transform [2.5 0 0 5.5 0 0]> |
| >>> |
| >>> 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 [12 0 0 1 0 0]> |
| >>> Transform(dx=12) |
| <Transform [1 0 0 1 12 0]> |
| >>> Transform(yx=12) |
| <Transform [1 0 12 1 0 0]> |
| |
| 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 |
| <Transform [0.2 0 0 0.3 0.08 0.18]> |
| >>> t2 |
| <Transform [0.2 0 0 0.3 0.08 0.18]> |
| >>> t1 == t2 |
| 0 |
| |
| Transform instances are hashable, meaning you can use them as |
| keys in dictionaries:: |
| |
| >>> d = {Scale(12, 13): None} |
| >>> d |
| {<Transform [12 0 0 13 0 0]>: 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 |
| <Transform [0.2 0 0 0.3 0.08 0.18]> |
| >>> t2 |
| <Transform [0.2 0 0 0.3 0.08 0.18]> |
| >>> d = {t1: None} |
| >>> d |
| {<Transform [0.2 0 0 0.3 0.08 0.18]>: None} |
| >>> d[t2] |
| Traceback (most recent call last): |
| File "<stdin>", line 1, in ? |
| KeyError: <Transform [0.2 0 0 0.3 0.08 0.18]> |
| """ |
| |
| 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) |
| <Transform [1 0 0 1 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) |
| <Transform [5 0 0 5 0 0]> |
| >>> t.scale(5, 6) |
| <Transform [5 0 0 6 0 0]> |
| >>> |
| """ |
| 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) |
| <Transform [0 1 -1 0 0 0]> |
| >>> |
| """ |
| 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) |
| <Transform [1 0 1 1 0 0]> |
| >>> |
| """ |
| 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)) |
| <Transform [8 9 4 3 11 24]> |
| >>> |
| """ |
| 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 [8 6 6 3 21 15]> |
| >>> Transform(4, 3, 2, 1, 5, 6).transform((2, 0, 0, 3, 1, 6)) |
| <Transform [8 6 6 3 21 15]> |
| >>> |
| """ |
| 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 __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) |
| <Transform [1 0 0 1 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) |
| <Transform [2 0 0 3 0 0]> |
| >>> |
| """ |
| if y is None: |
| y = x |
| return Transform(x, 0, 0, y, 0, 0) |
| |
| |
| if __name__ == "__main__": |
| import sys |
| import doctest |
| sys.exit(doctest.testmod().failed) |