"""drawing.py about ===== A graphics library for use in jupyter[1] python notebooks, built on top of the ipycanvas[2] package, with an API similar to parts of Zelle's graphics[3] library. It can draw pictures made up of points, text, circles, lines, rectangles, polygons, and even "crude" lines (something like hand-drawn lines) from a cell in a jupyter notebook. Here's an example. from drawing import * image = Drawing(400, 400) image.set_coords(0, 0, 1, 1) image.add( Polygon([Point(0.3,.4), Point(0.5,0.9), Point(0.8,0.4)]) ) image.add( Text( Point(0.2, 0.1), "It's a triangle!") ) image.draw() To use this library, the workflow is (1) Create a drawing object. (2) Add Shape objects to it. (3) Draw it. API === Here are the supplied classes and functions. Drawing The Drawing contains all the shapes to be drawn. drawing = Drawing(width, height) # size in pixels Optional arguments and their default values are : background = '#eeeeeeff' # nearly white opaque border = '#666666ff' # grey opaque border_width = 2 # in pixels drawing.set_coords(xll, yll, xur, yur) The coordinate system for the drawing and all its shapes can be set by defining a lower-left and upper-right corner. If not set, the default values are (xll, yll) = (0, height) (xur, yur) = (width, 0) which puts (0,0) at the top left with y increasing down. drawing.add(shape) # Add a shape (see below). drawing.draw() # display the drawing. drawing.to_file('filename.png') # After being displayed, a drawing # can be saved as a PNG image file. Shapes All the other classes represent graphical elements within the drawing, most with similar parameters. shapes optional arguments ----- ------------------ Point(x, y) color Text(point, message) color, face Line(point1, point2) color, line_width CrudeLine(point1, point2) color, line_width, crudity Circle(point, radius) color, outline, line_width Rectangle(point1, point2) color, outline, line_width Polygon([points]) color, outline, line_width Colors are given by a string, either names as defined in the CSS standard or as CSS hex string starting with #, such as '#ff00cc' (r,g,b 0 to 255) or '#00ff00cc' (r,g,b,t) where t is the transparency, also from 0 to 255. Google "CSS color" for more information on these data types. Two color conversion utility functions are provided which can generate color strings from (red,green,blue) or (r,g,b,t) values : color_css_string = color_rgb(red, green, blue) color_css_string = color_rgbt(red, green, blue, transparency) The Point class can be displayed, but is primarily intended for defining the positions of the other shapes. Points can be added and subtracted from each other, acting as two dimensional vectors. Scalar multiplication and the dot product are also supported. Points have one optional argument, color. The Text class displays a character string, with its left starting position defined by the given point. The font and text size can be set by the face string argument, which behaves much like the CSS font property. For example, face="12px sans-serif" or face="8px Times". The Line and CrudeLine classes draw a line between two points. The width can be adjusted with the line_width optional argument (whose units are pixels, not changed by set_coords.) A CrudeLine is an approximately placed, not-quite-straight line, which is intended to imitate a hand-drawn line. Its "crudity" optional argument, in pixels, sets roughly how far off the ends will be from their given position. The Rectangle, Circle, and Polygon may be filled in, have an outline, or both. The outline argument gives the color of the outline; if omitted then the outline is omitted. The width of the outline is given by the line_width parameter. complications ============= The simplest way to make a drawing appear, the .draw() method, has several limitations. A few other alternatives are implemented here, but all have their own issues. I) notebook saved images: drawing.render() ... drawing.display() The first issue is that .draw() does not put the graphics display into the jupyter notebook in a way that is saved when the notebook is saved. This means that if you download the notebook using "save_as_html", the drawing will not be part of the .html file. An alternative to .draw() that does put save the graphics in the notebook is to (a) .render() the drawing, and then in a different notebook cell (after the drawing has finished processing), (b) call .display() to show it. Then when the notebook is downloaded with "save as html", the images are visible. However, this does not work all in one cell: the drawing must be completely evaluated before .render() is called. I) animations : drawing.show() The second issue is animated drawings - things that change or move. The .draw() method shows the drawing all at once, with the underlying ipycanvas cache set to True, which has the advantage of being fast but does not permit animations. The draw.show() method is the start of allowing animations, since it displays graphics as soon as jupyter is ready. However, it uses a call-back routine which means that runtime errors fail silently. While animations were working in an earlier version of this software, they are not yet supported here. coordinates =========== The underlying canvas API allows for floating point coordinates, which is part of the model of the smooth bezier curves and paths supported by the underlying web canvas display system. So even though the drawings are displayed in terms of pixels, the default coordinate system actually refers to the edges of those pixels. Consider for example a canvas with a height of 2 pixels and width of 4 pixels. Using the default coordinate system, that looks like this : +----+----+----+----+ 0 = y | | | | | | | | | | +----+----+----+----+ 1 | | | | | | | | | | +----+----+----+----+ 2 0 1 2 3 4 = x footnotes ========= * [1] jupyter : https://jupyter.org * [2] ipycanvas : https://ipycanvas.readthedocs.io/en/latest/ * [3] Zelle's graphics : https://mcsp.wartburg.edu/zelle/python/ version ======= March 3 2021 | version 0.3 Tested with : python 3.9.1, jupyter core 4.7.0, jupyterhub 0.9.4, ipycanvas 0.8.2. Jim Mahoney | cs.bennington.college | MIT License """ from ipycanvas import Canvas import random, time import numpy as np from numpy import pi, sqrt, sin, cos, abs import matplotlib.pyplot as plt def color_rgb(red, green, blue): """ Given three intensities red, green, blue, all from 0 to 255, returns the corresponding CSS color string e.g. '#ff00cc' """ return f"#{red*256**2 + green*256 + blue:06x}" assert color_rgb(255, 255, 255) == '#ffffff' # tests assert color_rgb(0, 64, 255) == '#0040ff' assert color_rgb(0, 0, 0) == '#000000' def color_rgbt(red, green, blue, transparency): """ Given four intensities red, green, blue, transparency, all from 0 to 255, returns the corresponding CSS color string e.g. '#ff00ccff' """ return f"#{red*256**3 + green*256**2 + blue*256 + transparency:08x}" assert color_rgbt(255, 255, 255, 255) == '#ffffffff' assert color_rgbt(0, 64, 255, 128) == '#0040ff80' assert color_rgbt(0, 0, 0, 255) == '#000000ff' class Drawing(Canvas): """ A drawing made of shapes. """ defaults = { 'background' : '#eeeeeeff', # nearly white opaque 'border' : '#666666ff', # grey opaque 'border_width' : 2, # pixels 'cache_default': True, # display all components at end 'sync_image_data' : True # needed for get_image_data() } def __init__(self, width=500, height=500, **kwargs): # Examples : # Drawing(border=None) # omit border # Drawing(background='white') # white rather than default # Drawing(800, 200) # 800 pixels wide, 200 pixels high for key in Drawing.defaults.keys(): if kwargs.get(key, None) == None: kwargs[key] = Drawing.defaults[key] kwargs['caching'] = kwargs.get('caching', Drawing.defaults['cache_default']) Canvas.__init__(self, width=width, height=height, **kwargs) for key in Drawing.defaults.keys(): self.__dict__[key] = kwargs[key] self.width = width self.height = height self.caching = kwargs['caching'] self.components = [] # (self.xll, self.yll) = (0.0, 1.0 * height) (self.xur, self.yur) = (1.0 * width, 0.0) def set_coords(self, xll, yll, xur, yur): """ Set the drawing coordinate system where ll is 'lower left' and ur is 'upper right' """ self.xll = xll self.yll = yll self.xur = xur self.yur = yur def _xy(self, x, y): """ convert drawing coords (x,y) to canvas coords (_x,_y) with 0 <= _x <= width , 0 <= _y <= height """ _x = (x - self.xll) * self.width / (self.xur - self.xll) _y = (self.yur - y) * self.height / (self.yur - self.yll) return (_x, _y) def _size(self, xsize, ysize=None): """ convert drawing sizes to canvas _sizes , using x scale """ _xsize = xsize * self.width / abs(self.xur - self.xll) if ysize != None: _ysize = ysize * self.height / abs(self.yur - self.yll) return (_xsize, _ysize) else: return _xsize # To override all the canvas drawing routines with a new cord system, # translating (x,y) to (_x, _y), would take a lot of wrappers ... # so at this point I'm just modifying the _render() methods of shapes. #def fill_rect(self, x, y, width, height=None): pass #def fill_rects(self, x, y, width, height=None): pass #def stroke_rect(self, x, y, width, height=None): pass #def stroke_rects(self, x, y, width, height=None): pass #def clear_rect(x, y, width, height=None) #def fill_arc(self, x, y, radius, theta0, theta1): pass #def fill_arcs(self, x, y, radius, theta0, theta1): pass #def stroke_arc(self, x, y, radius, theta0, theta1): pass #def stroke_arcs(self, x, y, radius, theta0, theta1): pass #def move_to(self, x, y): pass #def line_to(self, x, y): pass #def line_width(self, size): pass #def rect(self, x, y, width, height): pass #def arc(self, x, y, width, height, anticlockwise=False): pass #def draw_image(self, image, x=0, y=0, width=None, height=None ): pass # and the text and image routines ... ugh. def add(self, shape): """ Add a shape to this drawing. """ # And remember this drawing in the shape. shape.drawing = self self.components.append(shape) def _myflush(self): """ flush if caching and reset """ if self.caching: self.flush() # side-effect: sets caching=False self.caching = self.cache_default def _init_render(self): """ Initalize drawing with default background color and border """ self.save() self.clear() if self.background: self.fill_style = self.background self.fill_rect(0, 0, self.width, self.height) if self.border: self.line_width = self.border_width self.stroke_style = self.border self.stroke_rect(0, 0, self.width, self.height) self._myflush() self.restore() def render(self): """ Render this drawing and its components. """ # This call alone doesn't make the drawing visible; # use .draw() or .show() or .display() to do that. self._init_render() for component in self.components: component.render() self._myflush() def display(self): """ Display a rendered drawing in a second jupyter notebook cell """ # Pros: # * saves images in notebook, same as matplotlib plots. # Cons: # * won't work for animations or interective widgets # * requires both render and display calls in two different cells. # Example : # ----- cell 1 ------------------- # | d = Drawing(100, 100) # | d.add( Line( Point(10, 10), Point(80, 90), 'red') # | d.render() # --------------------------------- # # ----- cell 2 ---------------------- # | d.display() # ------------------------------------ # # # HMMM ... # If I evaluate the cells as above one at a time, this works fine. # # But if I instead restart the kernel and evaluate all cells from # the notebook menu, the image data is not available when .display() # is called ... so I guess this is just too fragile and unreliable. # # It is also possible to save a drawing to a file with # self.to_file('filename.png') however this too like display() # requires that the image_data be available. # dpi = 300 plt.figure(figsize=(self.width/dpi, self.height/dpi), dpi=dpi) ax = plt.axes([0,0,1,1], frameon=False) ax.get_xaxis().set_visible(False) ax.get_yaxis().set_visible(False) plt.autoscale(tight=True) try: plt.imshow(self.get_image_data()) except Exception as e: print("Display failed; " "image data not yet available. \n" "Draw or render first then try again in another cell.") def write_file(self, filename='drawing.png'): """ Write drawing to a .png file """ # If the filename doesn't end in .png, append .png. if filename[-4:] != '.png': filename += '.png' try: self.to_file(filename) except Exception as e: print(f"Saving to '{filename}' failed; \n" "image data not yet available. \n" "Draw or render first then try again in another cell.") def show(self): """ Show a drawing or animation once jupyter is ready """ # Pros: # * all in one cell # * allows for animations # displaying components and modifications dynamically # Cons: # * errors fail silently in the callback handler # * the displayed images is only visible after the cell is evaluated; # it isn't saved in the notebook or in "download as HTML" # Example : # ----- cell 1 ------------------- # | d = Drawing(100, 100) # | d.add( Line( Point(10, 10), Point(80, 90), 'red') ) # | d.show() # --------------------------------- self.caching = False self.on_client_ready(lambda:self.render()) return self def draw(self): """ Draw this drawing below the current jupyter notebook cell in the client-side browser window. """ # # This is the recommended approach. # # Pros: # * all in one cell # * good for debugging, since error messages appear as expected # * uses the typical ipycanvas approach # Cons: # * the displayed images is only visible after the cell is evaluated, # drawn with an HTML5 js canvas in the client-side browser window. # And is therefore not in the server-side notebook itself and not # in the in "download as HTML" representation of the notebook. # Example: # ----- cell 1 ------------------- # | d = Drawing(100, 100) # | d.add( Line( Point(10, 10), Point(80, 90), 'red') ) # | d.draw() # --------------------------------- self.render() return self default_color = '#111111cc' # dark mostly opaque (default off-white background) default_line_width = 2 # pixel width of outline or line class Shape: """ A graphics component within a drawing """ def __init__(self, x=0, y=0, color=default_color, outline=None, line_width=default_line_width): # Zelle uses "fill" for what I'm calling "color" ... it's fill_color, eh? # And outline is actually outline_color. self.x = x self.y = y self.color = color self.outline = outline self.line_width = line_width def clone(self): its_clone = copy.deepcopy(self) its_clone.drawing = None return its_clone def render(self): if self.drawing: self.drawing.save() self._render() self.drawing.restore() def _render(self): pass # override this def _average(values): """ Return average of a list of numbers """ return sum(values)/len(values) assert _average( (1, 2, 3) ) == 2, 'average of three integers' assert _average( [10.0, 14.0] ) == 12.0, 'average of two floats' def _is_number(x): """ True if x is numeric i.e. int or float """ from numbers import Number return isinstance(x, Number) assert _is_number(2) == True, 'integer is a number' assert _is_number(3.2e-4) == True, 'float is a number' assert _is_number('three') == False, 'string is not a number' class Point(Shape): """ A 2D point with vector algebra. (Usually not drawn but instead used to define Shapes.) """ # implemented vector operations : # Point + Point => Point # Point - Point => Point # - Point => Point # Point * Point => number # dot product # scalar * Point => Point # Point * scalar => Point # Point == Point => boolean def __init__(self, x, y, color=default_color): Shape.__init__(self, x=x, y=y, color=color) def __repr__(self): if self.color == default_color: its_color = '' else: its_color = ', color={self.color}' return f"Point({self.x}, {self.y}{its_color})" def _render(self): canvas = self.drawing canvas.fill_style = self.color (_x, _y) = canvas._xy(self.x, self.y) # convert to canvas coords canvas.fill_arc(_x, _y, 1, 0.0, 2*pi) # radius 1 pixel def __add__(self, other): return Point(self.x + other.x, self.y + other.y, self.color) def __sub__(self, other): return Point(self.x - other.x, self.y - other.y, self.color) def __neg__(self): return Point(-self.x, -self.y, self.color) def __eq__(self, other): return self.x == other.x and self.y == other.y def __rmul__(self, other): return Point.__mul__(self, other) def __mul__(self, other): if _is_number(other): # scalar multiplication return Point(other * self.x, other * self.y, self.color) elif isinstance(other, Point): # dot product return self.x * other.x + self.y * other.y else: return NotImplemented _point_a = Point(3, 4) _point_b = Point(5.0, -1.0) assert (_point_a + _point_b).x == 8.0, 'Point addition' assert _point_a * _point_b == 11.0, 'Point dot product' assert _point_a * 2 == Point(6.0, 8.0), 'Point scalar product, point first' assert 3 * _point_b == Point(15.0, -3.0), 'Point scalar product, scalar first' # From https://github.com/martinRenou/ipycanvas/blob/master/ipycanvas/canvas.py ; # these are the _send_canvas_command values as of the Dec 17 2020 version of ipcanvas's canvas.py COMMANDS = { 'fillRect': 0, 'strokeRect': 1, 'fillRects': 2, 'strokeRects': 3, 'clearRect': 4, 'fillArc': 5, 'fillCircle': 6, 'strokeArc': 7, 'strokeCircle': 8, 'fillArcs': 9, 'strokeArcs': 10, 'fillCircles': 11, 'strokeCircles': 12, 'strokeLine': 13, 'beginPath': 14, 'closePath': 15, 'stroke': 16, 'fillPath': 17, 'fill': 18, 'moveTo': 19, 'lineTo': 20, 'rect': 21, 'arc': 22, 'ellipse': 23, 'arcTo': 24, 'quadraticCurveTo': 25, 'bezierCurveTo': 26, 'fillText': 27, 'strokeText': 28, 'setLineDash': 29, 'drawImage': 30, 'putImageData': 31, 'clip': 32, 'save': 33, 'restore': 34, 'translate': 35, 'rotate': 36, 'scale': 37, 'transform': 38, 'setTransform': 39, 'resetTransform': 40, 'set': 41, 'clear': 42, 'sleep': 43, 'fillPolygon': 44, 'strokePolygon': 45, 'strokeLines': 46, } class Circle(Shape): """ A circle defined by its center Point and radius """ # The drawing.set_coords() allows the x and y axes to have their own scales, # which means that circles will turn into ellipses if they're different. def __init__(self, center=Point(20, 20), radius=10, color=default_color, outline=None, line_width=default_line_width): Shape.__init__(self, x=center.x, y=center.y, color=color, outline=outline, line_width=line_width) self.radius = radius def _render(self): canvas = self.drawing canvas.begin_path() (_x, _y) = canvas._xy(self.x, self.y) # convert to canvas coords (_xradius, _yradius) = canvas._size(self.radius, self.radius) #canvas.ellipse(_x, _y, _xradius, _yradius, 0.0, 0.0, 2*pi) # ellipse is in in web canvas ... not in ipycanvas ? # See github.com/martinRenou/ipycanvas/blob/master/ipycanvas/canvas.py canvas._send_canvas_command(COMMANDS['ellipse'], (_x, _y, _xradius, _yradius, 0.0, 0.0, 2*pi)) if self.color: canvas.fill_style = self.color canvas.fill() if self.outline: canvas.stroke_style = self.outline canvas.stroke() class Line(Shape): """ A straight line between two Points """ def __init__(self, p0=Point(10,10), p1=Point(30,40), color=default_color, line_width=default_line_width): Shape.__init__(self, x=(p0.x + p1.x)/2, y=(p0.y + p1.y)/2, color=color, line_width=line_width) self.p0 = p0 self.p1 = p1 def _render(self): canvas = self.drawing canvas.begin_path() canvas.line_width = self.line_width canvas.stroke_style = self.color (_x0, _y0) = canvas._xy(self.p0.x, self.p0.y) (_x1, _y1) = canvas._xy(self.p1.x, self.p1.y) canvas.move_to(_x0, _y0) canvas.line_to(_x1, _y1) canvas.stroke() class Polygon(Shape): def __init__(self, points, color=default_color, outline=None, line_width=default_line_width): xs = list(p.x for p in points) ys = list(p.y for p in points) (x, y) = (_average(xs), _average(ys)) Shape.__init__(self, x=x, y=y, color=color, outline=outline, line_width=line_width) self.points = points def _trace(self): canvas = self.drawing if self.points: canvas.begin_path() (_x, _y) = canvas._xy(self.points[0].x, self.points[0].y) canvas.move_to(_x, _y) for p in self.points[1:]: (_x, _y) = canvas._xy(p.x, p.y) canvas.line_to(_x, _y) canvas.close_path() def _render(self): canvas = self.drawing self._trace() if self.color: canvas.fill_style = self.color canvas.fill() if self.outline: canvas.stroke_style = self.outline canvas.line_width = self.line_width canvas.stroke() class Text(Shape): def __init__(self, position=Point(10,10), text="Hello.", face="24px serif", color=default_color): Shape.__init__(self, x=position.x, y=position.y, color=color) self.text = text self.face = face self.position = position def _render(self): canvas = self.drawing canvas.font = self.face canvas.fill_style = self.color (_x, _y) = canvas._xy(self.position.x, self.position.y) canvas.fill_text(self.text, _x, _y) class Rectangle(Shape): """ An orthogonal box defined by two corner points. """ def __init__(self, p0=Point(10,10), p1=Point(30,50), color=default_color, outline=None, line_width=default_line_width): Shape.__init__(self, (p0.x + p1.x)/2, (p0.y + p1.y)/2, color=color, outline=outline, line_width=line_width) self.p0 = p0 self.p1 = p1 self.rect_width = abs(p1.x - p0.x) self.rect_height = abs(p1.y - p0.y) def _render(self): canvas = self.drawing # The web canvas api needs the top left corner. (_x, _y) = canvas._xy(self.p0.x, self.p1.y) (_width, _height) = canvas._size(self.rect_width, self.rect_height) if self.color: canvas.fill_style = self.color canvas.fill_rect(_x, _y, _width, _height) if self.outline: canvas.stroke_style = self.outline canvas.line_width = self.line_width canvas.stroke_rect(_x, _y, _width, _height) def _random_circle(): """ Return random (x, y) within a circle at (0,0) with radius 1 """ # I always have to think about how to justify the sqrt() in this algorithm. # The argument goes something like this. # * The random() function gives a uniform distribution from 0 to 1. # * Points chosen randomly in circular area are likely to have larger r, # since rings of width dr grow in size linearly; more area at larger r # so they are not distributed in a uniform distribution. # * If our random() function gives us lets say a value x=0.25, # then what that means is that 25% of the uniform points are # below that, and 75% of the uniform points are above. We need # to find the corresponding r for c(r), the circular # probability distribution, that has that same property that # 25% of the r values are smaller, and 75% are bigger. # * So what we really need to think about are not u(x) and c(r), but # U(x) and C(r), the cumulative probability distributions, with # dU/dx=u(x)=x and dC/dr=c(r)=2*x. In this case, these # cumulative distributions are U(x) = x and C(r) = r**2. # * Let's make the defintions explicit. # x = random variable 0 <= x < 1 with uniform probability. # r = random variable 0 <= r < 1 with circle radius probability. # u(x) = "probability density of finding x between x and x+dx" = 1 # c(r) = "probability densiity of finding r between r and r+dr" = 2*r # U(x) = "cumulative probability of finding x' from 0 to x' = x # C(r) = "cumulative probability of finding r' from 0 to r' = r**2 # * So at a give value like that 25% point, # U=0.25=C, r=sqrt(C)=sqrt(0.25)=0.5. # In other words, 25% of the circle has r<0.5, 75% has r>0.5. # * Therefore what we want is # C(r) = U(x) same cumulative prob. at corresponding r, x # r = C_inverse(U(x)) general formula for random variables r, x # r = sqrt(x) this case (r, theta) = (sqrt(random.random()), 2*pi*random.random()) return (r*cos(theta), r*sin(theta)) def _normalize(x, y): """ return (x_norm, y_norm) with unit length in same direction as (x,y) """ _length = sqrt(x*x + y*y) return (x/_length, y/_length) class CrudeLine(Shape): """ A crude "hand-drawn" line between two points """ # # Given line endpoints (start, end) and a "crudity" parameter, # draw a fairly-but-not-quite straight line from points near the ends. # # With crudity=0 this is just a straight line. As the crudity (in pixels) # grows, the endpoints get sloppier and and the line becomes more bowed. # # Based on the discussion at # shihn.ca/posts/2020/roughjs-algorithms/ # See also # en.wikipedia.org/wiki/Bézier_curve # developer.mozilla.org/en-US/docs/Web/API/\ # CanvasRenderingContext2D/bezierCurveTo) # # Thhis line (well, Bézier curve) is defined by four points : # A ------- B ---- C ------ D # where # A is within a circle centered at the start point with radius=crudity # B is a Bézier control point (i.e. not in the line), # setting an A -> B tangent to the curve at A, # placed (0.45 to 0.55) along length and (0.3 to 0.6) above the line, # C is a similar Bézier control point, # setting the C -> D tangent to the curve at D, # placed (0.65 to 0.75) along length and again (0.3 to 0.6) above, and # D is within a circle centered at the end point with radius=crudity. # def __init__(self, p0=Point(10,10), p1=Point(40,50), color=default_color, line_width=default_line_width, crudity=3): self.p0 = p0 self.p1 = p1 (_xa, _ya) = _random_circle() self.xa = xa = p0.x + _xa * crudity self.ya = ya = p0.y + _ya * crudity # (_x_para, _y_para) = _normalize(p1.x - p0.x, p1.y - p0.y) # parallel _length = sqrt((p1.x - p0.x)**2 + (p1.y - p0.y)**2) (_x_perp, _y_perp) = (- _y_para, _x_para) # perpendic. # _b_para = 0.45 + 0.1 * random.random() _b_perp = 0.3 * (1 + random.random()) * crudity self.xb = p0.x + _b_para * _length * _x_para + _b_perp * _x_perp self.yb = p0.y + _b_para * _length * _y_para + _b_perp * _y_perp # _c_para = 0.65 + 0.1 * random.random() _c_perp = 0.3 * (1 + random.random()) * crudity self.xc = self.p0.x + _c_para * _length * _x_para + _c_perp * _x_perp self.yc = self.p0.y + _c_para * _length * _y_para + _c_perp * _y_perp # (_xd, _yd) = _random_circle() self.xd = xd = p1.x + _xd * crudity self.yd = yd = p1.y + _yd * crudity # Shape.__init__(self, x=(xa+xd)/2, y=(ya+yd/2), color=color, outline=None, line_width=line_width) self.crudity = crudity def _render(self): canvas = self.drawing canvas.begin_path() canvas.line_width = self.line_width canvas.stroke_style = self.color (_xa, _ya) = canvas._xy(self.xa, self.ya) (_xb, _yb) = canvas._xy(self.xb, self.yb) (_xc, _yc) = canvas._xy(self.xc, self.yc) (_xd, _yd) = canvas._xy(self.xd, self.yd) canvas.move_to(_xa, _ya) canvas.bezier_curve_to(_xb, _yb, _xc, _yc, _xd, _yd) canvas.stroke() #if False: # # debugging by drawing its four bezier points # for (x,y) in ((self.xa, self.ya), (self.xb, self.yb), # (self.xc, self.yc), (self.xd, self.yd)): # canvas.fill_style = 'red' # canvas.fill_arc(x, y, 2, 0.0, 2*pi)