bezierTools.py revision befd485af59eb1d553beab340a32b02c9cb717af
1"""fontTools.misc.bezierTools.py -- tools for working with bezier path segments."""
2
3
4__all__ = ["calcQuadraticBounds", "calcCubicBounds", "splitLine", "splitQuadratic",
5	"splitCubic", "solveQuadratic", "solveCubic"]
6
7
8from fontTools.misc.arrayTools import calcBounds
9import Numeric
10
11
12def calcQuadraticBounds(pt1, pt2, pt3):
13	"""Return the bounding rectangle for a qudratic bezier segment.
14	pt1 and pt3 are the "anchor" points, pt2 is the "handle".
15
16		>>> calcQuadraticBounds((0, 0), (50, 100), (100, 0))
17		(0.0, 0.0, 100.0, 50.0)
18		>>> calcQuadraticBounds((0, 0), (100, 0), (100, 100))
19		(0.0, 0.0, 100.0, 100.0)
20	"""
21	a, b, c = calcQuadraticParameters(pt1, pt2, pt3)
22	# calc first derivative
23	ax, ay = a * 2
24	bx, by = b
25	roots = []
26	if ax != 0:
27		roots.append(-bx/ax)
28	if ay != 0:
29		roots.append(-by/ay)
30	points = [a*t*t + b*t + c for t in roots if 0 <= t < 1] + [pt1, pt3]
31	return calcBounds(points)
32
33
34def calcCubicBounds(pt1, pt2, pt3, pt4):
35	"""Return the bounding rectangle for a cubic bezier segment.
36	pt1 and pt4 are the "anchor" points, pt2 and pt3 are the "handles".
37
38		>>> calcCubicBounds((0, 0), (25, 100), (75, 100), (100, 0))
39		(0.0, 0.0, 100.0, 75.0)
40		>>> calcCubicBounds((0, 0), (50, 0), (100, 50), (100, 100))
41		(0.0, 0.0, 100.0, 100.0)
42		>>> calcCubicBounds((50, 0), (0, 100), (100, 100), (50, 0))
43		(35.566243270259356, 0.0, 64.433756729740679, 75.0)
44	"""
45	a, b, c, d = calcCubicParameters(pt1, pt2, pt3, pt4)
46	# calc first derivative
47	ax, ay = a * 3.0
48	bx, by = b * 2.0
49	cx, cy = c
50	xRoots = [t for t in solveQuadratic(ax, bx, cx) if 0 <= t < 1]
51	yRoots = [t for t in solveQuadratic(ay, by, cy) if 0 <= t < 1]
52	roots = xRoots + yRoots
53
54	points = [(a*t*t*t + b*t*t + c * t + d) for t in roots] + [pt1, pt4]
55	return calcBounds(points)
56
57
58def splitLine(pt1, pt2, where, isHorizontal):
59	"""Split the line between pt1 and pt2 at position 'where', which
60	is an x coordinate if isHorizontal is False, a y coordinate if
61	isHorizontal is True. Return a list of two line segments if the
62	line was successfully split, or a list containing the original
63	line.
64
65		>>> _testrepr(splitLine((0, 0), (100, 100), 50, True))
66		'(((0, 0), (50.0, 50.0)), ((50.0, 50.0), (100, 100)))'
67		>>> _testrepr(splitLine((0, 0), (100, 100), 100, True))
68		'(((0, 0), (100, 100)))'
69		>>> _testrepr(splitLine((0, 0), (100, 100), 0, True))
70		'(((0, 0), (0.0, 0.0)), ((0.0, 0.0), (100, 100)))'
71		>>> _testrepr(splitLine((0, 0), (100, 100), 0, False))
72		'(((0, 0), (0.0, 0.0)), ((0.0, 0.0), (100, 100)))'
73	"""
74	pt1, pt2 = Numeric.array((pt1, pt2))
75	a = (pt2 - pt1)
76	b = pt1
77	ax = a[isHorizontal]
78	if ax == 0:
79		return [(pt1, pt2)]
80	t = float(where - b[isHorizontal]) / ax
81	if 0 <= t < 1:
82		midPt = a * t + b
83		return [(pt1, midPt), (midPt, pt2)]
84	else:
85		return [(pt1, pt2)]
86
87
88def splitQuadratic(pt1, pt2, pt3, where, isHorizontal):
89	"""Split the quadratic curve between pt1, pt2 and pt3 at position 'where',
90	which is an x coordinate if isHorizontal is False, a y coordinate if
91	isHorizontal is True. Return a list of curve segments.
92
93		>>> splitQuadratic((0, 0), (50, 100), (100, 0), 150, False)
94		[((0, 0), (50, 100), (100, 0))]
95		>>> _testrepr(splitQuadratic((0, 0), (50, 100), (100, 0), 50, False))
96		'(((0.0, 0.0), (25.0, 50.0), (50.0, 50.0)), ((50.0, 50.0), (75.0, 50.0), (100.0, 0.0)))'
97		>>> _testrepr(splitQuadratic((0, 0), (50, 100), (100, 0), 25, False))
98		'(((0.0, 0.0), (12.5, 25.0), (25.0, 37.5)), ((25.0, 37.5), (62.5, 75.0), (100.0, 0.0)))'
99		>>> _testrepr(splitQuadratic((0, 0), (50, 100), (100, 0), 25, True))
100		'(((0.0, 0.0), (7.32233047034, 14.6446609407), (14.6446609407, 25.0)), ((14.6446609407, 25.0), (50.0, 75.0), (85.3553390593, 25.0)), ((85.3553390593, 25.0), (92.6776695297, 14.6446609407), (100.0, -7.1054273576e-15)))'
101		>>> # XXX I'm not at all sure it the following behavior is desirable:
102		>>> _testrepr(splitQuadratic((0, 0), (50, 100), (100, 0), 50, True))
103		'(((0.0, 0.0), (25.0, 50.0), (50.0, 50.0)), ((50.0, 50.0), (50.0, 50.0), (50.0, 50.0)), ((50.0, 50.0), (75.0, 50.0), (100.0, 0.0)))'
104	"""
105	a, b, c = calcQuadraticParameters(pt1, pt2, pt3)
106	solutions = solveQuadratic(a[isHorizontal], b[isHorizontal],
107		c[isHorizontal] - where)
108	solutions = [t for t in solutions if 0 <= t < 1]
109	solutions.sort()
110	if not solutions:
111		return [(pt1, pt2, pt3)]
112
113	segments = []
114	solutions.insert(0, 0.0)
115	solutions.append(1.0)
116	for i in range(len(solutions) - 1):
117		t1 = solutions[i]
118		t2 = solutions[i+1]
119		delta = (t2 - t1)
120		# calc new a, b and c
121		a1 = a * delta**2
122		b1 = (2*a*t1 + b) * delta
123		c1 = a*t1**2 + b*t1 + c
124		pt1, pt2, pt3 = calcQuadraticPoints(a1, b1, c1)
125		segments.append((pt1, pt2, pt3))
126	return segments
127
128
129def splitCubic(pt1, pt2, pt3, pt4, where, isHorizontal):
130	"""Split the cubic curve between pt1, pt2, pt3 and pt4 at position 'where',
131	which is an x coordinate if isHorizontal is False, a y coordinate if
132	isHorizontal is True. Return a list of curve segments.
133
134		>>> splitCubic((0, 0), (25, 100), (75, 100), (100, 0), 150, False)
135		[((0, 0), (25, 100), (75, 100), (100, 0))]
136		>>> _testrepr(splitCubic((0, 0), (25, 100), (75, 100), (100, 0), 50, False))
137		'(((0.0, 0.0), (12.5, 50.0), (31.25, 75.0), (50.0, 75.0)), ((50.0, 75.0), (68.75, 75.0), (87.5, 50.0), (100.0, 0.0)))'
138		>>> _testrepr(splitCubic((0, 0), (25, 100), (75, 100), (100, 0), 25, True))
139		'(((0.0, 0.0), (2.2937927384, 9.17517095361), (4.79804488188, 17.5085042869), (7.47413641001, 25.0)), ((7.47413641001, 25.0), (31.2886200204, 91.6666666667), (68.7113799796, 91.6666666667), (92.52586359, 25.0)), ((92.52586359, 25.0), (95.2019551181, 17.5085042869), (97.7062072616, 9.17517095361), (100.0, 1.7763568394e-15)))'
140	"""
141	a, b, c, d = calcCubicParameters(pt1, pt2, pt3, pt4)
142	solutions = solveCubic(a[isHorizontal], b[isHorizontal], c[isHorizontal],
143		d[isHorizontal] - where)
144	solutions = [t for t in solutions if 0 <= t < 1]
145	solutions.sort()
146	if not solutions:
147		return [(pt1, pt2, pt3, pt4)]
148
149	segments = []
150	solutions.insert(0, 0.0)
151	solutions.append(1.0)
152	for i in range(len(solutions) - 1):
153		t1 = solutions[i]
154		t2 = solutions[i+1]
155		delta = (t2 - t1)
156		# calc new a, b, c and d
157		a1 = a * delta**3
158		b1 = (3*a*t1 + b) * delta**2
159		c1 = (2*b*t1 + c + 3*a*t1**2) * delta
160		d1 = a*t1**3 + b*t1**2 + c*t1 + d
161		pt1, pt2, pt3, pt4 = calcCubicPoints(a1, b1, c1, d1)
162		segments.append((pt1, pt2, pt3, pt4))
163	return segments
164
165
166#
167# Equation solvers.
168#
169
170from math import sqrt, acos, cos, pi
171
172
173def solveQuadratic(a, b, c,
174		sqrt=sqrt):
175	"""Solve a quadratic equation where a, b and c are real.
176	    a*x*x + b*x + c = 0
177	This function returns a list of roots. Note that the returned list
178	is neither guaranteed to be sorted nor to contain unique values!
179	"""
180	if a == 0.0:
181		if b == 0.0:
182			# We have a non-equation; therefore, we have no valid solution
183			roots = []
184		else:
185			# We have a linear equation with 1 root.
186			roots = [-c/b]
187	else:
188		# We have a true quadratic equation.  Apply the quadratic formula to find two roots.
189		DD = b*b - 4.0*a*c
190		if DD >= 0.0:
191			rDD = sqrt(DD)
192			roots = [(-b+rDD)/2.0/a, (-b-rDD)/2.0/a]
193		else:
194			# complex roots, ignore
195			roots = []
196	return roots
197
198
199def solveCubic(a, b, c, d,
200		abs=abs, pow=pow, sqrt=sqrt, cos=cos, acos=acos, pi=pi):
201	"""Solve a cubic equation where a, b, c and d are real.
202	    a*x*x*x + b*x*x + c*x + d = 0
203	This function returns a list of roots. Note that the returned list
204	is neither guaranteed to be sorted nor to contain unique values!
205	"""
206	#
207	# adapted from:
208	#   CUBIC.C - Solve a cubic polynomial
209	#   public domain by Ross Cottrell
210	# found at: http://www.strangecreations.com/library/snippets/Cubic.C
211	#
212	if abs(a) < 1e-6:
213		# don't just test for zero; for very small values of 'a' solveCubic()
214		# returns unreliable results, so we fall back to quad.
215		return solveQuadratic(b, c, d)
216	a = float(a)
217	a1 = b/a
218	a2 = c/a
219	a3 = d/a
220
221	Q = (a1*a1 - 3.0*a2)/9.0
222	R = (2.0*a1*a1*a1 - 9.0*a1*a2 + 27.0*a3)/54.0
223	R2_Q3 = R*R - Q*Q*Q
224
225	if R2_Q3 < 0:
226		theta = acos(R/sqrt(Q*Q*Q))
227		rQ2 = -2.0*sqrt(Q)
228		x0 = rQ2*cos(theta/3.0) - a1/3.0
229		x1 = rQ2*cos((theta+2.0*pi)/3.0) - a1/3.0
230		x2 = rQ2*cos((theta+4.0*pi)/3.0) - a1/3.0
231		return [x0, x1, x2]
232	else:
233		if Q == 0 and R == 0:
234			x = 0
235		else:
236			x = pow(sqrt(R2_Q3)+abs(R), 1/3.0)
237			x = x + Q/x
238		if R >= 0.0:
239			x = -x
240		x = x - a1/3.0
241		return [x]
242
243
244def calcQuadraticParameters(pt1, pt2, pt3):
245	pt1, pt2, pt3 = Numeric.array((pt1, pt2, pt3))
246	c = pt1
247	b = (pt2 - c) * 2.0
248	a = pt3 - c - b
249	return a, b, c
250
251
252def calcCubicParameters(pt1, pt2, pt3, pt4):
253	pt1, pt2, pt3, pt4 = Numeric.array((pt1, pt2, pt3, pt4))
254	d = pt1
255	c = (pt2 - d) * 3.0
256	b = (pt3 - pt2) * 3.0 - c
257	a = pt4 - d - c - b
258	return a, b, c, d
259
260
261def calcQuadraticPoints(a, b, c):
262	pt1 = c
263	pt2 = (b * 0.5) + c
264	pt3 = a + b + c
265	return pt1, pt2, pt3
266
267
268def calcCubicPoints(a, b, c, d):
269	pt1 = d
270	pt2 = (c / 3.0) + d
271	pt3 = (b + c) / 3.0 + pt2
272	pt4 = a + d + c + b
273	return pt1, pt2, pt3, pt4
274
275
276def _testrepr(obj):
277	"""
278		>>> _testrepr([1, [2, 3], [], [[2, [3, 4]]]])
279		'(1, (2, 3), (), ((2, (3, 4))))'
280	"""
281	try:
282		it = iter(obj)
283	except TypeError:
284		return str(obj)
285	else:
286		return "(%s)" % ", ".join([_testrepr(x) for x in it])
287
288
289if __name__ == "__main__":
290	import doctest
291	doctest.testmod()
292