added curvecorners() decorator
[PyX/mjg.git] / pyx / deco.py
blob731eb2e3be09eca35fcd2c1301e23fb9bfaadce3
1 #!/usr/bin/env python
2 # -*- coding: ISO-8859-1 -*-
5 # Copyright (C) 2002-2004 Jörg Lehmann <joergl@users.sourceforge.net>
6 # Copyright (C) 2003-2004 Michael Schindler <m-schindler@users.sourceforge.net>
7 # Copyright (C) 2002-2004 André Wobst <wobsta@users.sourceforge.net>
9 # This file is part of PyX (http://pyx.sourceforge.net/).
11 # PyX is free software; you can redistribute it and/or modify
12 # it under the terms of the GNU General Public License as published by
13 # the Free Software Foundation; either version 2 of the License, or
14 # (at your option) any later version.
16 # PyX is distributed in the hope that it will be useful,
17 # but WITHOUT ANY WARRANTY; without even the implied warranty of
18 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19 # GNU General Public License for more details.
21 # You should have received a copy of the GNU General Public License
22 # along with PyX; if not, write to the Free Software
23 # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
25 # TODO:
26 # - should we improve on the arc length -> arg parametrization routine or
27 # should we at least factor it out?
29 import math
30 import attr, base, canvas, helper, path, style, trafo, unit
33 # Decorated path
36 class decoratedpath(base.PSCmd):
37 """Decorated path
39 The main purpose of this class is during the drawing
40 (stroking/filling) of a path. It collects attributes for the
41 stroke and/or fill operations.
42 """
44 def __init__(self, path, strokepath=None, fillpath=None,
45 styles=None, strokestyles=None, fillstyles=None,
46 subcanvas=None):
48 self.path = path
50 # path to be stroked or filled (or None)
51 self.strokepath = strokepath
52 self.fillpath = fillpath
54 # global style for stroking and filling and subdps
55 self.styles = helper.ensurelist(styles)
57 # styles which apply only for stroking and filling
58 self.strokestyles = helper.ensurelist(strokestyles)
59 self.fillstyles = helper.ensurelist(fillstyles)
61 # the canvas can contain additional elements of the path, e.g.,
62 # arrowheads,
63 if subcanvas is None:
64 self.subcanvas = canvas.canvas()
65 else:
66 self.subcanvas = subcanvas
69 def bbox(self):
70 scbbox = self.subcanvas.bbox()
71 pbbox = self.path.bbox()
72 if scbbox is not None:
73 return scbbox+pbbox
74 else:
75 return pbbox
77 def prolog(self):
78 result = []
79 for style in list(self.styles) + list(self.fillstyles) + list(self.strokestyles):
80 result.extend(style.prolog())
81 result.extend(self.subcanvas.prolog())
82 return result
84 def outputPS(self, file):
85 # draw (stroke and/or fill) the decoratedpath on the canvas
86 # while trying to produce an efficient output, e.g., by
87 # not writing one path two times
89 # small helper
90 def _writestyles(styles, file=file):
91 for style in styles:
92 style.outputPS(file)
94 # apply global styles
95 if self.styles:
96 canvas._gsave().outputPS(file)
97 _writestyles(self.styles)
99 if self.fillpath is not None:
100 canvas._newpath().outputPS(file)
101 self.fillpath.outputPS(file)
103 if self.strokepath==self.fillpath:
104 # do efficient stroking + filling
105 canvas._gsave().outputPS(file)
107 if self.fillstyles:
108 _writestyles(self.fillstyles)
110 canvas._fill().outputPS(file)
111 canvas._grestore().outputPS(file)
113 if self.strokestyles:
114 canvas._gsave().outputPS(file)
115 _writestyles(self.strokestyles)
117 canvas._stroke().outputPS(file)
119 if self.strokestyles:
120 canvas._grestore().outputPS(file)
121 else:
122 # only fill fillpath - for the moment
123 if self.fillstyles:
124 canvas._gsave().outputPS(file)
125 _writestyles(self.fillstyles)
127 canvas._fill().outputPS(file)
129 if self.fillstyles:
130 canvas._grestore().outputPS(file)
132 if self.strokepath is not None and self.strokepath!=self.fillpath:
133 # this is the only relevant case still left
134 # Note that a possible stroking has already been done.
136 if self.strokestyles:
137 canvas._gsave().outputPS(file)
138 _writestyles(self.strokestyles)
140 canvas._newpath().outputPS(file)
141 self.strokepath.outputPS(file)
142 canvas._stroke().outputPS(file)
144 if self.strokestyles:
145 canvas._grestore().outputPS(file)
147 if self.strokepath is None and self.fillpath is None:
148 raise RuntimeError("Path neither to be stroked nor filled")
150 # now, draw additional elements of decoratedpath
151 self.subcanvas.outputPS(file)
153 # restore global styles
154 if self.styles:
155 canvas._grestore().outputPS(file)
157 def outputPDF(self, file):
158 # draw (stroke and/or fill) the decoratedpath on the canvas
159 # while trying to produce an efficient output, e.g., by
160 # not writing one path two times
162 def _writestyles(styles, file=file):
163 for style in styles:
164 style.outputPDF(file)
166 # apply global styles
167 if self.styles:
168 canvas._gsave().outputPDF(file)
169 _writestyles(self.styles)
171 if self.fillpath is not None:
172 canvas._newpath().outputPDF(file)
173 self.fillpath.outputPDF(file)
175 if self.strokepath==self.fillpath:
176 # do efficient stroking + filling
177 canvas._gsave().outputPDF(file)
179 if self.fillstyles:
180 _writestyles(self.fillstyles)
182 canvas._fill().outputPDF(file)
183 canvas._grestore().outputPDF(file)
185 if self.strokestyles:
186 canvas._gsave().outputPDF(file)
187 _writestyles(self.strokestyles)
189 canvas._stroke().outputPDF(file)
191 if self.strokestyles:
192 canvas._grestore().outputPDF(file)
193 else:
194 # only fill fillpath - for the moment
195 if self.fillstyles:
196 canvas._gsave().outputPDF(file)
197 _writestyles(self.fillstyles)
199 canvas._fill().outputPDF(file)
201 if self.fillstyles:
202 canvas._grestore().outputPDF(file)
204 if self.strokepath is not None and self.strokepath!=self.fillpath:
205 # this is the only relevant case still left
206 # Note that a possible stroking has already been done.
208 if self.strokestyles:
209 canvas._gsave().outputPDF(file)
210 _writestyles(self.strokestyles)
212 canvas._newpath().outputPDF(file)
213 self.strokepath.outputPDF(file)
214 canvas._stroke().outputPDF(file)
216 if self.strokestyles:
217 canvas._grestore().outputPDF(file)
219 if self.strokepath is None and self.fillpath is None:
220 raise RuntimeError("Path neither to be stroked nor filled")
222 # now, draw additional elements of decoratedpath
223 self.subcanvas.outputPDF(file)
225 # restore global styles
226 if self.styles:
227 canvas._grestore().outputPDF(file)
230 # Path decorators
233 class deco:
235 """decorators
237 In contrast to path styles, path decorators depend on the concrete
238 path to which they are applied. In particular, they don't make
239 sense without any path and can thus not be used in canvas.set!
243 def decorate(self, dp):
244 """apply a style to a given decoratedpath object dp
246 decorate accepts a decoratedpath object dp, applies PathStyle
247 by modifying dp in place and returning the new dp.
250 pass
253 # stroked and filled: basic decos which stroked and fill,
254 # respectively the path
257 class _stroked(deco, attr.exclusiveattr):
259 """stroked is a decorator, which draws the outline of the path"""
261 def __init__(self, styles=[]):
262 attr.exclusiveattr.__init__(self, _stroked)
263 self.styles = attr.mergeattrs(styles)
264 attr.checkattrs(self.styles, [style.strokestyle])
266 def __call__(self, styles=[]):
267 # XXX or should we also merge self.styles
268 return _stroked(styles)
270 def decorate(self, dp):
271 dp.strokepath = dp.path
272 dp.strokestyles = self.styles
273 return dp
275 stroked = _stroked()
276 stroked.clear = attr.clearclass(_stroked)
279 class _filled(deco, attr.exclusiveattr):
281 """filled is a decorator, which fills the interior of the path"""
283 def __init__(self, styles=[]):
284 attr.exclusiveattr.__init__(self, _filled)
285 self.styles = attr.mergeattrs(styles)
286 attr.checkattrs(self.styles, [style.fillstyle])
288 def __call__(self, styles=[]):
289 # XXX or should we also merge self.styles
290 return _filled(styles)
292 def decorate(self, dp):
293 dp.fillpath = dp.path
294 dp.fillstyles = self.styles
295 return dp
297 filled = _filled()
298 filled.clear = attr.clearclass(_filled)
301 # Arrows
304 # two helper functions which construct the arrowhead and return its size, respectively
306 def _arrowheadtemplatelength(anormpath, size):
307 "calculate length of arrowhead template (in parametrisation of anormpath)"
308 # get tip (tx, ty)
309 tx, ty = anormpath.begin()
311 # obtain arrow template by using path up to first intersection
312 # with circle around tip (as suggested by Michael Schindler)
313 ipar = anormpath.intersect(path.circle(tx, ty, size))
314 if ipar[0]:
315 alen = ipar[0][0]
316 else:
317 # if this doesn't work, use first order conversion from pts to
318 # the bezier curve's parametrization
319 tlen = anormpath.tangent(0).arclength_pt()
320 try:
321 alen = unit.topt(size)/tlen
322 except ArithmeticError:
323 # take maximum, we can get
324 alen = anormpath.range()
325 if alen > anormpath.range(): alen = anormpath.range()
327 return alen
330 def _arrowhead(anormpath, size, angle, constriction):
332 """helper routine, which returns an arrowhead for a normpath
334 returns arrowhead at begin of anormpath with size,
335 opening angle and relative constriction
338 alen = _arrowheadtemplatelength(anormpath, size)
339 tx, ty = anormpath.begin()
341 # now we construct the template for our arrow but cutting
342 # the path a the corresponding length
343 arrowtemplate = anormpath.split([alen])[0]
345 # from this template, we construct the two outer curves
346 # of the arrow
347 arrowl = arrowtemplate.transformed(trafo.rotate(-angle/2.0, tx, ty))
348 arrowr = arrowtemplate.transformed(trafo.rotate( angle/2.0, tx, ty))
350 # now come the joining backward parts
351 if constriction:
352 # arrow with constriction
354 # constriction point (cx, cy) lies on path
355 cx, cy = anormpath.at(constriction*alen)
357 arrowcr= path.line(*(arrowr.end()+(cx,cy)))
359 arrow = arrowl.reversed() << arrowr << arrowcr
360 arrow.append(path.closepath())
361 else:
362 # arrow without constriction
363 arrow = arrowl.reversed() << arrowr
364 arrow.append(path.closepath())
366 return arrow
369 _base = unit.v_pt(4)
371 class arrow(deco, attr.attr):
373 """arrow is a decorator which adds an arrow to either side of the path"""
375 def __init__(self, attrs=[], position=0, size=_base, angle=45, constriction=0.8):
376 self.attrs = attr.mergeattrs([style.linestyle.solid, stroked, filled] + attrs)
377 attr.checkattrs(self.attrs, [deco, style.fillstyle, style.strokestyle])
378 self.position = position
379 self.size = unit.length(size, default_type="v")
380 self.angle = angle
381 self.constriction = constriction
383 def __call__(self, attrs=None, position=None, size=None, angle=None, constriction=None):
384 if attrs is None:
385 attrs = self.attrs
386 if position is None:
387 position = self.position
388 if size is None:
389 size = self.size
390 if angle is None:
391 angle = self.angle
392 if constriction is None:
393 constriction = self.constriction
394 return arrow(attrs=attrs, position=position, size=size, angle=angle, constriction=constriction)
396 def decorate(self, dp):
397 # XXX raise exception error, when strokepath is not defined
399 # convert to normpath if necessary
400 if isinstance(dp.strokepath, path.normpath):
401 anormpath = dp.strokepath
402 else:
403 anormpath = path.normpath(dp.path)
404 if self.position:
405 anormpath = anormpath.reversed()
407 # add arrowhead to decoratedpath
408 dp.subcanvas.draw(_arrowhead(anormpath, self.size, self.angle, self.constriction),
409 self.attrs)
411 # calculate new strokepath
412 alen = _arrowheadtemplatelength(anormpath, self.size)
413 if self.constriction:
414 ilen = alen*self.constriction
415 else:
416 ilen = alen
418 # correct somewhat for rotation of arrow segments
419 ilen = ilen*math.cos(math.pi*self.angle/360.0)
421 # this is the rest of the path, we have to draw
422 anormpath = anormpath.split([ilen])[1]
424 # go back to original orientation, if necessary
425 if self.position:
426 anormpath.reverse()
428 # set the new (shortened) strokepath
429 dp.strokepath = anormpath
431 return dp
433 arrow.clear = attr.clearclass(arrow)
435 # arrows at begin of path
436 barrow = arrow(position=0)
437 barrow.SMALL = barrow(size=_base/math.sqrt(64))
438 barrow.SMALl = barrow(size=_base/math.sqrt(32))
439 barrow.SMAll = barrow(size=_base/math.sqrt(16))
440 barrow.SMall = barrow(size=_base/math.sqrt(8))
441 barrow.Small = barrow(size=_base/math.sqrt(4))
442 barrow.small = barrow(size=_base/math.sqrt(2))
443 barrow.normal = barrow(size=_base)
444 barrow.large = barrow(size=_base*math.sqrt(2))
445 barrow.Large = barrow(size=_base*math.sqrt(4))
446 barrow.LArge = barrow(size=_base*math.sqrt(8))
447 barrow.LARge = barrow(size=_base*math.sqrt(16))
448 barrow.LARGe = barrow(size=_base*math.sqrt(32))
449 barrow.LARGE = barrow(size=_base*math.sqrt(64))
451 # arrows at end of path
452 earrow = arrow(position=1)
453 earrow.SMALL = earrow(size=_base/math.sqrt(64))
454 earrow.SMALl = earrow(size=_base/math.sqrt(32))
455 earrow.SMAll = earrow(size=_base/math.sqrt(16))
456 earrow.SMall = earrow(size=_base/math.sqrt(8))
457 earrow.Small = earrow(size=_base/math.sqrt(4))
458 earrow.small = earrow(size=_base/math.sqrt(2))
459 earrow.normal = earrow(size=_base)
460 earrow.large = earrow(size=_base*math.sqrt(2))
461 earrow.Large = earrow(size=_base*math.sqrt(4))
462 earrow.LArge = earrow(size=_base*math.sqrt(8))
463 earrow.LARge = earrow(size=_base*math.sqrt(16))
464 earrow.LARGe = earrow(size=_base*math.sqrt(32))
465 earrow.LARGE = earrow(size=_base*math.sqrt(64))
468 class wriggle(deco, attr.attr):
470 def __init__(self, skipleft=1, skipright=1, radius=0.5, loops=8, curvesperloop=4):
471 self.skipleft_str = skipleft
472 self.skipright_str = skipright
473 self.radius_str = radius
474 self.loops = loops
475 self.curvesperloop = curvesperloop
477 def decorate(self, dp):
478 # XXX: is this the correct way to select the basepath???!!!
479 if isinstance(dp.strokepath, path.normpath):
480 basepath = dp.strokepath
481 elif dp.strokepath is not None:
482 basepath = path.normpath(dp.strokepath)
483 elif isinstance(dp.path, path.normpath):
484 basepath = dp.path
485 else:
486 basepath = path.normpath(dp.path)
488 skipleft = unit.topt(unit.length(self.skipleft_str, default_type="v"))
489 skipright = unit.topt(unit.length(self.skipright_str, default_type="v"))
490 startpar, endpar = basepath.lentopar(map(unit.t_pt, [skipleft, basepath.arclength_pt() - skipright]))
491 radius = unit.topt(unit.length(self.radius_str))
493 # search for the first intersection of a circle around start point x, y bigger than startpar
494 x, y = basepath.at_pt(startpar)
495 startcircpar = None
496 for intersectpar in basepath.intersect(path.circle_pt(x, y, radius))[0]:
497 if startpar < intersectpar and (startcircpar is None or startcircpar > intersectpar):
498 startcircpar = intersectpar
499 if startcircpar is None:
500 raise RuntimeError("couldn't find wriggle start point")
501 # calculate start position and angle
502 xcenter, ycenter = basepath.at_pt(startcircpar)
503 startpos = basepath.split([startcircpar])[0].arclength_pt()
504 startangle = math.atan2(y-ycenter, x-xcenter)
506 # find the last intersection of a circle around x, y smaller than endpar
507 x, y = basepath.at_pt(endpar)
508 endcircpar = None
509 for intersectpar in basepath.intersect(path.circle_pt(x, y, radius))[0]:
510 if endpar > intersectpar and (endcircpar is None or endcircpar < intersectpar):
511 endcircpar = intersectpar
512 if endcircpar is None:
513 raise RuntimeError("couldn't find wriggle end point")
514 # calculate end position and angle
515 x2, y2 = basepath.at_pt(endcircpar)
516 endpos = basepath.split([endcircpar])[0].arclength_pt()
517 endangle = math.atan2(y-y2, x-x2)
519 if endangle < startangle:
520 endangle += 2*math.pi
522 # calculate basepath points
523 sections = self.loops * self.curvesperloop
524 posrange = endpos - startpos
525 poslist = [startpos + i*posrange/sections for i in range(sections+1)]
526 parlist = basepath.lentopar(map(unit.t_pt, poslist))
527 atlist = [basepath.at_pt(x) for x in parlist]
529 # from pyx import color
530 # for at in atlist:
531 # dp.subcanvas.stroke(path.circle_pt(at[0], at[1], 1), [color.rgb.blue])
533 # calculate wriggle points and tangents
534 anglerange = 2*math.pi*self.loops + endangle - startangle
535 deltaangle = anglerange / sections
536 tangentlength = radius * 4 * (1 - math.cos(deltaangle/2)) / (3 * math.sin(deltaangle/2))
537 wriggleat = [None]*(sections+1)
538 wriggletangentstart = [None]*(sections+1)
539 wriggletangentend = [None]*(sections+1)
540 for i in range(sections+1):
541 x, y = atlist[i]
542 angle = startangle + i*anglerange/sections
543 dx, dy = math.cos(angle), math.sin(angle)
544 wriggleat[i] = x + radius*dx, y + radius*dy
545 # dp.subcanvas.stroke(path.line_pt(x, y, x + radius*dx, y + radius*dy), [color.rgb.blue])
546 wriggletangentstart[i] = x + radius*dx + tangentlength*dy, y + radius*dy - tangentlength*dx
547 wriggletangentend[i] = x + radius*dx - tangentlength*dy, y + radius*dy + tangentlength*dx
549 # build wriggle path
550 wrigglepath = basepath.split([startpar])[0]
551 wrigglepath.append(path.multicurveto_pt([wriggletangentend[i-1] +
552 wriggletangentstart[i] +
553 wriggleat[i]
554 for i in range(1, sections+1)]))
555 wrigglepath = wrigglepath.glue(basepath.split([endpar])[1]) # glue and glued?!?
557 # store wriggle path
558 dp.path = wrigglepath # otherwise the bbox is wrong!
559 dp.strokepath = wrigglepath
560 return dp
563 class curvecorners(deco, attr.attr):
565 """bends corners in a path
567 curvecorners replaces corners between two lines in a path by an optimized
568 curve that has zero curvature at the connections to the lines.
569 Corners between curves and lines are left as they are."""
571 def __init__(self, radius=None, softness=1):
572 self.radius = unit.topt(radius)
573 self.softness = softness
575 def controlpoints_pt(self, A, B, C, r1, r2, softness):
576 # Takes three endpoints of two straight lines:
577 # start A, connecting midpoint B, endpoint C
578 # and two radii r1 and r2:
579 # Returns the seven control points of the two bezier curves:
580 # - start d1
581 # - control points g1 and f1
582 # - midpoint e
583 # - control points f2 and g2
584 # - endpoint d2
586 def normed(v):
587 n = math.sqrt(v[0] * v[0] + v[1] * v[1])
588 return v[0] / n, v[1] / n
589 # make direction vectors d1: from B to A
590 # d2: from B to C
591 d1 = normed([A[i] - B[i] for i in [0,1]])
592 d2 = normed([C[i] - B[i] for i in [0,1]])
594 # 0.3192 has turned out to be the maximum softness available
595 # for straight lines ;-)
596 f = 0.3192 * softness
597 g = (15.0 * f + math.sqrt(-15.0*f*f + 24.0*f))/12.0
599 # make the control points
600 f1 = [B[i] + f * r1 * d1[i] for i in [0,1]]
601 f2 = [B[i] + f * r2 * d2[i] for i in [0,1]]
602 g1 = [B[i] + g * r1 * d1[i] for i in [0,1]]
603 g2 = [B[i] + g * r2 * d2[i] for i in [0,1]]
604 d1 = [B[i] + r1 * d1[i] for i in [0,1]]
605 d2 = [B[i] + r2 * d2[i] for i in [0,1]]
606 e = [0.5 * (f1[i] + f2[i]) for i in [0,1]]
608 return [d1, g1, f1, e, f2, g2, d2]
610 def decorate(self, dp):
611 # XXX: is this the correct way to select the basepath???!!!
612 # compare to wriggle()
613 if isinstance(dp.strokepath, path.normpath):
614 basepath = dp.strokepath
615 elif dp.strokepath is not None:
616 basepath = path.normpath(dp.strokepath)
617 elif isinstance(dp.path, path.normpath):
618 basepath = dp.path
619 else:
620 basepath = path.normpath(dp.path)
622 newpath = path.path()
623 for subpath in basepath.subpaths:
624 newpels = []
625 # it is not clear yet, where to moveto (e.g. with a closed subpath we
626 # will get the starting point when inserting the bended corner)
627 domoveto = subpath.begin_pt()
628 dolineto = None
630 # for a closed subpath we eventually have to bend the initial corner
631 if subpath.closed:
632 A = subpath.normpathels[-1].begin_pt()
633 previsline = isinstance(subpath.normpathels[-1], path.normline)
634 else:
635 A = subpath.begin_pt()
636 previsline = 0
638 # go through the list of normpathels in this subpath
639 for i in range(len(subpath.normpathels)):
640 # XXX: at the moment, we have to build up a path, not a normpath
641 # this should be changed later
642 thispel = subpath.normpathels[i]
643 prevpel = subpath.normpathels[i-1]
644 # from this pel: B,C, thisstraight
645 # from previus pel: A, prevstraight
646 B, C = thispel.begin_pt(), thispel.end_pt()
647 thisisline = isinstance(thispel, path.normline)
648 if thisisline and previsline:
649 d1,g1,f1,e,f2,g2,d2 = self.controlpoints_pt(A,B,C, self.radius, self.radius, self.softness)
650 if domoveto is not None:
651 newpath.append(path.moveto_pt(d1[0],d1[1]))
652 if dolineto is not None:
653 newpath.append(path.lineto_pt(d1[0],d1[1]))
654 newpath.append(path.curveto_pt(*(g1 + f1 + e)))
655 newpath.append(path.curveto_pt(*(f2 + g2 + d2)))
656 dolineto = C
657 else:
658 if domoveto is not None:
659 newpath.append(path.moveto_pt(*domoveto))
660 if dolineto is not None:
661 newpath.append(path.lineto_pt(*dolineto))
662 if isinstance(thispel, path.normcurve):
663 # convert the normcurve to a curveto
664 newpath.append(path.curveto_pt(thispel.x1,thispel.y1,thispel.x2,thispel.y2,thispel.x3,thispel.y3))
665 dolineto = None
666 elif isinstance (thispel, path.normline):
667 dolineto = C # just store something here which is not None
669 domoveto = None
670 previsline = thisisline
671 A = B
673 if dolineto is not None:
674 newpath.append(path.lineto_pt(*dolineto))
675 if subpath.closed:
676 newpath.append(path.closepath())
678 newpath = path.normpath(newpath)
679 dp.path = newpath
680 dp.strokepath = newpath
681 return dp