# -*- coding: utf-8 -*- # Copyright © 2017 Kevin Thibedeau # Distributed under the terms of the MIT license from __future__ import print_function import os import math def rounded_corner(start, apex, end, rad): # Translate all points with apex at origin start = (start[0] - apex[0], start[1] - apex[1]) end = (end[0] - apex[0], end[1] - apex[1]) # Get angles of each line segment enter_a = math.atan2(start[1], start[0]) % math.radians(360) leave_a = math.atan2(end[1], end[0]) % math.radians(360) #print('## enter, leave', math.degrees(enter_a), math.degrees(leave_a)) # Determine bisector angle ea2 = abs(enter_a - leave_a) if ea2 > math.radians(180): ea2 = math.radians(360) - ea2 bisect = ea2 / 2.0 if bisect > math.radians(82): # Nearly colinear: Skip radius return (apex, apex, apex, -1) q = rad * math.sin(math.radians(90) - bisect) / math.sin(bisect) # Check that q is no more than half the shortest leg enter_leg = math.sqrt(start[0]**2 + start[1]**2) leave_leg = math.sqrt(end[0]**2 + end[1]**2) short_leg = min(enter_leg, leave_leg) if q > short_leg / 2: q = short_leg / 2 # Compute new radius rad = q * math.sin(bisect) / math.sin(math.radians(90) - bisect) h = math.sqrt(q**2 + rad**2) # Center of circle # Determine which direction is the smallest angle to the leave point # Determine direction of arc # Rotate whole system so that enter_a is on x-axis delta = (leave_a - enter_a) % math.radians(360) if delta < math.radians(180): # CW bisect = enter_a + bisect else: # CCW bisect = enter_a - bisect #print('## Bisect2', math.degrees(bisect)) center = (h * math.cos(bisect) + apex[0], h * math.sin(bisect) + apex[1]) # Find start and end point of arcs start_p = (q * math.cos(enter_a) + apex[0], q * math.sin(enter_a) + apex[1]) end_p = (q * math.cos(leave_a) + apex[0], q * math.sin(leave_a) + apex[1]) return (center, start_p, end_p, rad) def rotate_bbox(box, a): '''Rotate a bounding box 4-tuple by an angle in degrees''' corners = ( (box[0], box[1]), (box[0], box[3]), (box[2], box[3]), (box[2], box[1]) ) a = -math.radians(a) sa = math.sin(a) ca = math.cos(a) rot = [] for p in corners: rx = p[0]*ca + p[1]*sa ry = -p[0]*sa + p[1]*ca rot.append((rx,ry)) # Find the extrema of the rotated points rot = list(zip(*rot)) rx0 = min(rot[0]) rx1 = max(rot[0]) ry0 = min(rot[1]) ry1 = max(rot[1]) #print('## RBB:', box, rot) return (rx0, ry0, rx1, ry1) class BaseSurface(object): def __init__(self, fname, def_styles, padding=0, scale=1.0): self.fname = fname self.def_styles = def_styles self.padding = padding self.scale = scale self.draw_bbox = False self.markers = {} self.shape_drawers = {} def add_shape_class(self, sclass, drawer): self.shape_drawers[sclass] = drawer def render(self, canvas, transparent=False): pass def text_bbox(self, text, font_params, spacing): pass ################################# ## NuCANVAS objects ################################# class DrawStyle(object): def __init__(self): # Set defaults self.weight = 1 self.line_color = (0,0,255) self.line_cap = 'butt' # self.arrows = True self.fill = None self.text_color = (0,0,0) self.font = ('Helvetica', 12, 'normal') self.anchor = 'center' class BaseShape(object): def __init__(self, options, **kwargs): self.options = {} if options is None else options self.options.update(kwargs) self._bbox = [0,0,1,1] self.tags = set() @property def points(self): return tuple(self._bbox) @property def bbox(self): if 'weight' in self.options: w = self.options['weight'] / 2.0 else: w = 0 x0 = min(self._bbox[0], self._bbox[2]) x1 = max(self._bbox[0], self._bbox[2]) y0 = min(self._bbox[1], self._bbox[3]) y1 = max(self._bbox[1], self._bbox[3]) x0 -= w x1 += w y0 -= w y1 += w return (x0,y0,x1,y1) @property def width(self): x0, _, x1, _ = self.bbox return x1 - x0 @property def height(self): _, y0, _, y1 = self.bbox return y1 - y0 @property def size(self): x0, y1, x1, y1 = self.bbox return (x1-x0, y1-y0) def param(self, name, def_styles=None): if name in self.options: return self.options[name] elif def_styles is not None: return getattr(def_styles, name) else: return None def is_tagged(self, item): return item in self.tags def update_tags(self): if 'tags' in self.options: self.tags = self.tags.union(self.options['tags']) del self.options['tags'] def move(self, dx, dy): if self._bbox is not None: self._bbox[0] += dx self._bbox[1] += dy self._bbox[2] += dx self._bbox[3] += dy def dtag(self, tag=None): if tag is None: self.tags.clear() else: self.tags.discard(tag) def addtag(self, tag=None): if tag is not None: self.tags.add(tag) def draw(self, c): pass def make_group(self): '''Convert a shape into a group''' parent = self.options['parent'] # Walk up the parent hierarchy until we find a GroupShape with a surface ref p = parent while not isinstance(p, GroupShape): p = p.options['parent'] surf = p.surf g = GroupShape(surf, 0,0, {'parent': parent}) # Add this shape as a child of the new group g.shapes.append(self) self.options['parent'] = g # Replace this shape in the parent's child list parent.shapes = [c if c is not self else g for c in parent.shapes] return g class GroupShape(BaseShape): def __init__(self, surf, x0, y0, options, **kwargs): BaseShape.__init__(self, options, **kwargs) self._pos = (x0,y0) self._bbox = None self.shapes = [] self.surf = surf # Needed for TextShape to get font metrics # self.parent = None # if 'parent' in options: # self.parent = options['parent'] # del options['parent'] self.update_tags() def ungroup(self): if self.parent is None: return # Can't ungroup top level canvas group x, y = self._pos for s in self.shapes: s.move(x, y) if isinstance(s, GroupShape): s.parent = self.parent # Transfer group children to our parent pshapes = self.parent.shapes pos = pshapes.index(self) # Remove this group self.parent.shapes = pshapes[:pos] + self.shapes + pshapes[pos+1:] def ungroup_all(self): for s in self.shapes: if isinstance(s, GroupShape): s.ungroup_all() self.ungroup() def move(self, dx, dy): BaseShape.move(self, dx, dy) self._pos = (self._pos[0] + dx, self._pos[1] + dy) def create_shape(self, sclass, x0, y0, x1, y1, **options): options['parent'] = self shape = sclass(x0, y0, x1, y1, options) self.shapes.append(shape) self._bbox = None # Invalidate memoized box return shape def create_group(self, x0, y0, **options): options['parent'] = self shape = GroupShape(self.surf, x0, y0, options) self.shapes.append(shape) self._bbox = None # Invalidate memoized box return shape def create_group2(self, sclass, x0, y0, **options): options['parent'] = self shape = sclass(self.surf, x0, y0, options) self.shapes.append(shape) self._bbox = None # Invalidate memoized box return shape def create_arc(self, x0, y0, x1, y1, **options): return self.create_shape(ArcShape, x0, y0, x1, y1, **options) def create_line(self, x0, y0, x1, y1, **options): return self.create_shape(LineShape, x0, y0, x1, y1, **options) def create_oval(self, x0, y0, x1, y1, **options): return self.create_shape(OvalShape, x0, y0, x1, y1, **options) def create_rectangle(self, x0, y0, x1, y1, **options): return self.create_shape(RectShape, x0, y0, x1, y1, **options) def create_text(self, x0, y0, **options): # Must set default font now so we can use its metrics to get bounding box if 'font' not in options: options['font'] = self.surf.def_styles.font shape = TextShape(x0, y0, self.surf, options) self.shapes.append(shape) self._bbox = None # Invalidate memoized box # Add a unique tag to serve as an ID id_tag = 'id' + str(TextShape.next_text_id) shape.tags.add(id_tag) #return id_tag # FIXME return shape def create_path(self, nodes, **options): shape = PathShape(nodes, options) self.shapes.append(shape) self._bbox = None # Invalidate memoized box return shape @property def bbox(self): if self._bbox is None: bx0 = 0 bx1 = 0 by0 = 0 by1 = 0 boxes = [s.bbox for s in self.shapes] boxes = list(zip(*boxes)) if len(boxes) > 0: bx0 = min(boxes[0]) by0 = min(boxes[1]) bx1 = max(boxes[2]) by1 = max(boxes[3]) if 'scale' in self.options: sx = sy = self.options['scale'] bx0 *= sx by0 *= sy bx1 *= sx by1 *= sy if 'angle' in self.options: bx0, by0, bx1, by1 = rotate_bbox((bx0, by0, bx1, by1), self.options['angle']) tx, ty = self._pos self._bbox = [bx0+tx, by0+ty, bx1+tx, by1+ty] return self._bbox def dump_shapes(self, indent=0): print('{}{}'.format(' '*indent, repr(self))) indent += 1 for s in self.shapes: if isinstance(s, GroupShape): s.dump_shapes(indent) else: print('{}{}'.format(' '*indent, repr(s))) class LineShape(BaseShape): def __init__(self, x0, y0, x1, y1, options=None, **kwargs): BaseShape.__init__(self, options, **kwargs) self._bbox = [x0, y0, x1, y1] self.update_tags() class RectShape(BaseShape): def __init__(self, x0, y0, x1, y1, options=None, **kwargs): BaseShape.__init__(self, options, **kwargs) self._bbox = [x0, y0, x1, y1] self.update_tags() class OvalShape(BaseShape): def __init__(self, x0, y0, x1, y1, options=None, **kwargs): BaseShape.__init__(self, options, **kwargs) self._bbox = [x0, y0, x1, y1] self.update_tags() class ArcShape(BaseShape): def __init__(self, x0, y0, x1, y1, options=None, **kwargs): if 'closed' not in options: options['closed'] = False BaseShape.__init__(self, options, **kwargs) self._bbox = [x0, y0, x1, y1] self.update_tags() @property def bbox(self): lw = self.param('weight') if lw is None: lw = 0 lw /= 2.0 # Calculate bounding box for arc segment x0, y0, x1, y1 = self.points xc = (x0 + x1) / 2.0 yc = (y0 + y1) / 2.0 hw = abs(x1 - x0) / 2.0 hh = abs(y1 - y0) / 2.0 start = self.options['start'] % 360 extent = self.options['extent'] stop = (start + extent) % 360 if extent < 0: start, stop = stop, start # Swap points so we can rotate CCW if stop < start: stop += 360 # Make stop greater than start angles = [start, stop] # Find the extrema of the circle included in the arc ortho = (start // 90) * 90 + 90 while ortho < stop: angles.append(ortho) ortho += 90 # Rotate CCW # Convert all extrema points to cartesian points = [(hw * math.cos(math.radians(a)), -hh * math.sin(math.radians(a))) for a in angles] points = list(zip(*points)) x0 = min(points[0]) + xc - lw y0 = min(points[1]) + yc - lw x1 = max(points[0]) + xc + lw y1 = max(points[1]) + yc + lw if 'weight' in self.options: w = self.options['weight'] / 2.0 # FIXME: This doesn't properly compensate for the true extrema of the stroked outline x0 -= w x1 += w y0 -= w y1 += w #print('@@ ARC BB:', (bx0,by0,bx1,by1), hw, hh, angles, start, extent) return (x0,y0,x1,y1) class PathShape(BaseShape): def __init__(self, nodes, options=None, **kwargs): BaseShape.__init__(self, options, **kwargs) self.nodes = nodes self.update_tags() @property def bbox(self): extrema = [] for p in self.nodes: if len(p) == 2: extrema.append(p) elif len(p) == 6: # FIXME: Compute tighter extrema of spline extrema.append(p[0:2]) extrema.append(p[2:4]) extrema.append(p[4:6]) elif len(p) == 5: # Arc extrema.append(p[0:2]) extrema.append(p[2:4]) extrema = list(zip(*extrema)) x0 = min(extrema[0]) y0 = min(extrema[1]) x1 = max(extrema[0]) y1 = max(extrema[1]) if 'weight' in self.options: w = self.options['weight'] / 2.0 # FIXME: This doesn't properly compensate for the true extrema of the stroked outline x0 -= w x1 += w y0 -= w y1 += w return (x0, y0, x1, y1) class TextShape(BaseShape): text_id = 1 def __init__(self, x0, y0, surf, options=None, **kwargs): BaseShape.__init__(self, options, **kwargs) self._pos = (x0, y0) if 'spacing' not in options: options['spacing'] = -8 if 'anchor' not in options: options['anchor'] = 'c' spacing = options['spacing'] bx0,by0, bx1,by1, baseline = surf.text_bbox(options['text'], options['font'], spacing) w = bx1 - bx0 h = by1 - by0 self._baseline = baseline self._bbox = [x0, y0, x0+w, y0+h] self._anchor_off = self.anchor_offset self.update_tags() @property def bbox(self): x0, y0, x1, y1 = self._bbox ax, ay = self._anchor_off return (x0+ax, y0+ay, x1+ax, y1+ay) @property def anchor_decode(self): anchor = self.param('anchor').lower() anchor = anchor.replace('center','c') anchor = anchor.replace('east','e') anchor = anchor.replace('west','w') if 'e' in anchor: anchorh = 'e' elif 'w' in anchor: anchorh = 'w' else: anchorh = 'c' if 'n' in anchor: anchorv = 'n' elif 's' in anchor: anchorv = 's' else: anchorv = 'c' return (anchorh, anchorv) @property def anchor_offset(self): x0, y0, x1, y1 = self._bbox w = abs(x1 - x0) h = abs(y1 - y0) hw = w / 2.0 hh = h / 2.0 spacing = self.param('spacing') anchorh, anchorv = self.anchor_decode ax = 0 ay = 0 if 'n' in anchorv: ay = hh + (spacing // 2) elif 's' in anchorv: ay = -hh - (spacing // 2) if 'e' in anchorh: ax = -hw elif 'w' in anchorh: ax = hw # Convert from center to upper-left corner return (ax - hw, ay - hh) @property def next_text_id(self): rval = TextShape.text_id TextShape.text_id += 1 return rval class DoubleRectShape(BaseShape): def __init__(self, x0, y0, x1, y1, options=None, **kwargs): BaseShape.__init__(self, options, **kwargs) self._bbox = [x0, y0, x1, y1] self.update_tags() def cairo_draw_DoubleRectShape(shape, surf): c = surf.ctx x0, y0, x1, y1 = shape.points c.rectangle(x0,y0, x1-x0,y1-y0) stroke = True if shape.options['weight'] > 0 else False if 'fill' in shape.options: c.set_source_rgba(*rgb_to_cairo(shape.options['fill'])) if stroke: c.fill_preserve() else: c.fill() if stroke: # FIXME c.set_source_rgba(*default_pen) c.set_source_rgba(*rgb_to_cairo((100,200,100))) c.stroke() c.rectangle(x0+4,y0+4, x1-x0-8,y1-y0-8) c.stroke()