• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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